Compare commits

...
153 Commits
Author SHA1 Message Date
Emil 1ec4b2e60b Point the wizard's task runner at the venv python automatically
coordinator / test (push) Waiting to run
python / test (push) Waiting to run
release / binaries (amd64, darwin) (push) Waiting to run
release / binaries (amd64, linux) (push) Waiting to run
release / binaries (amd64, windows) (push) Waiting to run
release / binaries (arm64, darwin) (push) Waiting to run
release / binaries (arm64, linux) (push) Waiting to run
release / binaries (arm64, windows) (push) Waiting to run
release / wheel (push) Waiting to run
release / release (push) Blocked by required conditions
release / image (push) Waiting to run
users / test (push) Waiting to run
2026-08-03 03:16:23 +03:00
Emil d309145290 Set the agent version before dispatching the setup wizard
coordinator / test (push) Waiting to run
python / test (push) Waiting to run
release / binaries (amd64, darwin) (push) Waiting to run
release / binaries (amd64, linux) (push) Waiting to run
release / binaries (amd64, windows) (push) Waiting to run
release / binaries (arm64, darwin) (push) Waiting to run
release / binaries (arm64, linux) (push) Waiting to run
release / binaries (arm64, windows) (push) Waiting to run
release / wheel (push) Waiting to run
release / release (push) Blocked by required conditions
release / image (push) Waiting to run
users / test (push) Waiting to run
2026-08-03 03:05:55 +03:00
Emil 15dd9651a8 Ship the scimesh wheel in releases; wizard and serve install it version-locked
coordinator / test (push) Waiting to run
python / test (push) Waiting to run
release / binaries (amd64, darwin) (push) Waiting to run
release / binaries (amd64, linux) (push) Waiting to run
release / binaries (amd64, windows) (push) Waiting to run
release / binaries (arm64, darwin) (push) Waiting to run
release / binaries (arm64, linux) (push) Waiting to run
release / binaries (arm64, windows) (push) Waiting to run
release / wheel (push) Waiting to run
release / release (push) Blocked by required conditions
release / image (push) Waiting to run
users / test (push) Waiting to run
2026-08-03 02:59:22 +03:00
Emil 30c441a7e9 Require an explicit scimesh source in the worker wizard (PyPI name is another project)
coordinator / test (push) Waiting to run
python / test (push) Waiting to run
release / binaries (amd64, darwin) (push) Waiting to run
release / binaries (amd64, linux) (push) Waiting to run
release / binaries (amd64, windows) (push) Waiting to run
release / binaries (arm64, darwin) (push) Waiting to run
release / binaries (arm64, linux) (push) Waiting to run
release / binaries (arm64, windows) (push) Waiting to run
release / release (push) Blocked by required conditions
release / image (push) Waiting to run
users / test (push) Waiting to run
2026-08-03 02:39:24 +03:00
Emil cb51172885 Let the worker wizard install the scimesh runtime into a managed venv
coordinator / test (push) Waiting to run
python / test (push) Waiting to run
release / binaries (amd64, darwin) (push) Waiting to run
release / binaries (amd64, linux) (push) Waiting to run
release / binaries (amd64, windows) (push) Waiting to run
release / binaries (arm64, darwin) (push) Waiting to run
release / binaries (arm64, linux) (push) Waiting to run
release / binaries (arm64, windows) (push) Waiting to run
release / release (push) Blocked by required conditions
release / image (push) Waiting to run
users / test (push) Waiting to run
2026-08-03 02:32:06 +03:00
Emil 029b26e6ae Explain the admin-required error and offer account switching on the login page
coordinator / test (push) Waiting to run
python / test (push) Waiting to run
release / binaries (amd64, darwin) (push) Waiting to run
release / binaries (amd64, linux) (push) Waiting to run
release / binaries (amd64, windows) (push) Waiting to run
release / binaries (arm64, darwin) (push) Waiting to run
release / binaries (arm64, linux) (push) Waiting to run
release / binaries (arm64, windows) (push) Waiting to run
release / release (push) Blocked by required conditions
release / image (push) Waiting to run
users / test (push) Waiting to run
2026-08-03 02:06:45 +03:00
Emil 12499d2d7b Remove the demo control room; /ui lands on the admin console
coordinator / test (push) Waiting to run
python / test (push) Waiting to run
release / binaries (amd64, darwin) (push) Waiting to run
release / binaries (amd64, linux) (push) Waiting to run
release / binaries (amd64, windows) (push) Waiting to run
release / binaries (arm64, darwin) (push) Waiting to run
release / binaries (arm64, linux) (push) Waiting to run
release / binaries (arm64, windows) (push) Waiting to run
release / release (push) Blocked by required conditions
release / image (push) Waiting to run
users / test (push) Waiting to run
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) Waiting to run
python / test (push) Waiting to run
release / binaries (amd64, darwin) (push) Waiting to run
release / binaries (amd64, linux) (push) Waiting to run
release / binaries (amd64, windows) (push) Waiting to run
release / binaries (arm64, darwin) (push) Waiting to run
release / binaries (arm64, linux) (push) Waiting to run
release / binaries (arm64, windows) (push) Waiting to run
release / release (push) Blocked by required conditions
release / image (push) Waiting to run
users / test (push) Waiting to run
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) Waiting to run
python / test (push) Waiting to run
release / binaries (amd64, darwin) (push) Waiting to run
release / binaries (amd64, linux) (push) Waiting to run
release / binaries (amd64, windows) (push) Waiting to run
release / binaries (arm64, darwin) (push) Waiting to run
release / binaries (arm64, linux) (push) Waiting to run
release / binaries (arm64, windows) (push) Waiting to run
release / release (push) Blocked by required conditions
release / image (push) Waiting to run
users / test (push) Waiting to run
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) Waiting to run
python / test (push) Waiting to run
release / binaries (amd64, darwin) (push) Waiting to run
release / binaries (amd64, linux) (push) Waiting to run
release / binaries (amd64, windows) (push) Waiting to run
release / binaries (arm64, darwin) (push) Waiting to run
release / binaries (arm64, linux) (push) Waiting to run
release / binaries (arm64, windows) (push) Waiting to run
release / release (push) Blocked by required conditions
release / image (push) Waiting to run
users / test (push) Waiting to run
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) Waiting to run
python / test (push) Waiting to run
release / binaries (amd64, darwin) (push) Waiting to run
release / binaries (amd64, linux) (push) Waiting to run
release / binaries (amd64, windows) (push) Waiting to run
release / binaries (arm64, darwin) (push) Waiting to run
release / binaries (arm64, linux) (push) Waiting to run
release / binaries (arm64, windows) (push) Waiting to run
release / release (push) Blocked by required conditions
release / image (push) Waiting to run
users / test (push) Waiting to run
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) Waiting to run
python / test (push) Waiting to run
release / binaries (amd64, darwin) (push) Waiting to run
release / binaries (amd64, linux) (push) Waiting to run
release / binaries (amd64, windows) (push) Waiting to run
release / binaries (arm64, darwin) (push) Waiting to run
release / binaries (arm64, linux) (push) Waiting to run
release / binaries (arm64, windows) (push) Waiting to run
release / release (push) Blocked by required conditions
release / image (push) Waiting to run
users / test (push) Waiting to run
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) Waiting to run
python / test (push) Waiting to run
release / binaries (amd64, darwin) (push) Waiting to run
release / binaries (amd64, linux) (push) Waiting to run
release / binaries (amd64, windows) (push) Waiting to run
release / binaries (arm64, darwin) (push) Waiting to run
release / binaries (arm64, linux) (push) Waiting to run
release / binaries (arm64, windows) (push) Waiting to run
release / release (push) Blocked by required conditions
release / image (push) Waiting to run
users / test (push) Waiting to run
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
Emil 8c5350abdf Filter release artifacts to binaries in the download step
coordinator / test (push) Waiting to run
python / test (push) Waiting to run
release / binaries (amd64, darwin) (push) Waiting to run
release / binaries (amd64, linux) (push) Waiting to run
release / binaries (amd64, windows) (push) Waiting to run
release / binaries (arm64, darwin) (push) Waiting to run
release / binaries (arm64, linux) (push) Waiting to run
release / binaries (arm64, windows) (push) Waiting to run
release / release (push) Blocked by required conditions
release / image (push) Waiting to run
users / test (push) Waiting to run
2026-08-02 18:48:57 +03:00
Emil 9fe81bc531 Use golang-migrate-compatible watermark tracking in migrations 2026-08-02 18:37:15 +03:00
Emil b281d5811a Fix migration path in the coordinator CI workflow 2026-08-02 18:32:20 +03:00
Emil 771952e22e Fix golangci-lint findings across the coordinator and agent 2026-08-02 18:23:43 +03:00
Emil 565d4466e4 Run gofmt on the setup wizard and migration test 2026-08-02 18:16:44 +03:00
Emil 59e7fb0155 Add the coordinator setup wizard for self-provisioned databases 2026-08-02 18:14:36 +03:00
Emil 320f52615e Embed schema migrations and self-provision the schema on startup 2026-08-02 18:04:00 +03:00
Emil f2977a990e Add versioned release builds for coordinator and worker 2026-08-02 17:54:06 +03:00
Emil 5665e7df98 Document workload-declared UI and the upload contract 2026-08-02 17:47:52 +03:00
Emil 361eb2e344 Advertise all catalog workloads from the worker by default 2026-08-02 17:47:52 +03:00
Emil 749396da05 Drive coordinator upload and reduction from the workload catalog 2026-08-02 17:47:47 +03:00
Emil 700a96a259 Load every enabled built-in workload in the worker by default 2026-08-02 17:47:47 +03:00
Emil 5d738e0a14 Add SDK-declared workload UI elements and reduction metadata 2026-08-02 17:47:43 +03:00
Emil a18b8b8ae4 Build the coordinator server as a static binary 2026-08-02 16:56:06 +03:00
Emil 706bc85e17 Replace Python worker daemon with the Go worker agent 2026-08-02 16:53:04 +03:00
Emil 9a8221163a Remove dead code and wire the two-worker smoke script 2026-08-02 16:36:05 +03:00
Emil 644c287002 Add Go worker agent prototype 2026-08-02 16:27:58 +03:00
Emil f20cc7fe00 Serve documentation from the operator UI 2026-08-02 15:50:07 +03:00
Emil 284aef5d6f Set up MkDocs Material documentation site 2026-08-02 01:22:45 +03:00
Emil f059ac626c Add workload library page to the operator UI 2026-08-02 01:18:03 +03:00
Emil 5c5a2af0a1 Add molwt-filter workload with default scaffold hooks 2026-08-02 01:09:13 +03:00
Emil bc76f386e5 Add MapReduceWorkload scaffold and generic workload execution 2026-08-02 01:02:24 +03:00
Emil 19fbb8e926 Replace legacy distributed protocol with SDK-built workloads 2026-08-01 23:57:41 +03:00
Emil 96169086f0 Add descriptor-batch SDK reference workload 2026-08-01 23:27:53 +03:00
Emil c43af32495 Add workload SDK foundation 2026-08-01 23:22:20 +03:00
Emil 11e9333033 Define generalized workload SDK contract 2026-08-01 16:30:25 +03:00
Emil 0a759a3f01 Update workload SDK roadmap 2026-08-01 16:17:19 +03:00
Emil b9a975b0ea Merge self-service worker enrollment 2026-07-27 22:28:04 +03:00
Emil a201dd5ef9 Reject stale sessions before worker key creation
users / test (push) Waiting to run
2026-07-27 22:28:02 +03:00
Emil dc75411907 Record user service integration 2026-07-27 22:23:34 +03:00
Emil 6cdc115d60 Merge user service and coordinator integration 2026-07-27 22:23:21 +03:00
Emil fa76133efc Secure user worker operations
coordinator / test (push) Waiting to run
users / test (push) Waiting to run
2026-07-27 22:23:08 +03:00
Emil 7d8998408c Merge branch 'main' into feat/users 2026-07-27 22:19:43 +03:00
Efremenko Arhip e2a57175a0 fix(users): use errors.Is in worker key name test (errorlint) 2026-07-27 16:57:49 +03:00
Efremenko Arhip 2991ed202b feat(demo): add demo-reset to wipe the demo's data volumes
demo-down / stop intentionally keep the Postgres and artifact volumes, so a
fresh start still carried old workers/jobs/tasks. Add a reset action (compose
down -v) and a make demo-reset target for a truly pristine restart.
2026-07-27 16:48:06 +03:00
Efremenko Arhip 9b235282fc fix(coordinator/ui): use the entered machine name in the generated worker command
The Add-your-machine page sent the typed name only to the worker key, while
the copyable command hard-coded --worker-name my-machine, so every self-service
worker registered as my-machine regardless of what the user entered. Thread the
name into the command (shell-quoted; falls back to my-machine when blank).
2026-07-27 16:48:06 +03:00
Efremenko Arhip 3a1461315f feat: self-service worker enrollment bound to a user account
Let a signed-in user turn their own machine into a worker without the
shared token. The coordinator already binds a JWT-authenticated
registration to owner_id as untrusted; this adds the missing pieces.

userservice: long-lived worker keys (scimesh_wk_live_*, hash-at-rest)
with create/list/revoke and a public /worker-tokens/exchange that trades
a key for a short-lived JWT carrying the owner current role/verified.

python worker: SCIMESH_WORKER_KEY + SCIMESH_USERSERVICE_URL; a token
provider exchanges the key and refreshes the JWT proactively and on 401,
so a long-running worker survives token expiry. Static bearer token path
is unchanged.

coordinator UI: an "add your machine" page that mints a key and shows a
ready-to-run command, proxying key management to the userservice; the
dashboard gains an owner-scoped "my machines" section.

docs: how to run a worker from your account, plus the untrusted/quorum/
verified trust model.
2026-07-27 16:11:07 +03:00
Efremenko Arhip 172ff76fb8 fix(test): update NewCompleteTask call to new signature
Pass workers/results repos and quorum in the postgres integration
test, matching the constructor change that added quorum voting.
2026-07-27 11:29:52 +03:00
Efremenko Arhip 6f14eeb32e fix(coordinator): silence nilerr on unresolvable-worker trust fallback 2026-07-27 11:05:36 +03:00
Emil 87a483c2fb Plan user service integration 2026-07-27 01:39:26 +03:00
Efremenko Arhip 18d58cce84 feat(coordinator): quorum verification for untrusted (volunteer) results
Replaces the C1 quarantine with real verification. Trusted results (lab token,
verified, or admin worker) are accepted directly as before. An untrusted
worker's result is recorded as one vote per (task, owner) in a new task_results
table; the task only completes once QUORUM_SIZE distinct owners submit the same
result hash, otherwise it returns to the queue for another independent compute.

- migration 0013 task_results (one vote per owner, quorum by result_sha256)
- CompleteTask branches on worker trust; unknown worker defaults trusted (safe:
  completing needs the lease, whose owner is always a known registered worker)
- claim drops the quarantine and excludes chunks the owner already voted on
- domain Task.ReleaseAfterVote; QUORUM_SIZE config (default 2)
- unit tests: trusted direct-complete, untrusted needs-quorum, can-claim

Reducer and job done/total logic untouched — still one completed task per chunk.
2026-07-26 22:55:51 +03:00
Efremenko Arhip dcabfcd0c3 feat(coordinator): business metrics — jobs/tasks/workers by status
A scrape-time collector reports scimesh_tasks/jobs/workers gauges keyed by
status, sourced from cheap GROUP BY queries (StatsRepo), zero-filled across all
known statuses so the dashboard shows flat zeros instead of gaps. A failed
query yields no samples for that scrape rather than crashing it.

Metrics is now built in main so the DB-backed collector can be registered
(NewServer takes *metrics.Metrics; nil self-provisions for tests). Grafana
dashboard gains a Domain state row: tasks/jobs/workers by status and a queue-
depth stat.
2026-07-26 22:16:35 +03:00
Efremenko Arhip e9cf6f0842 build(coordinator): go 1.25 (prometheus deps require it), bump Docker base
The metrics deps pull in modules whose go directive is 1.25, so go mod tidy
raised the module to go 1.25.0. The build image is bumped golang:1.24 ->
golang:1.25-alpine to match (the image runs GOTOOLCHAIN=local and can't
auto-fetch a newer toolchain). CI reads go-version-file, so it follows along.
client_golang pinned to v1.19.1 (stable, same API).
2026-07-26 21:59:49 +03:00
Efremenko Arhip 779ff8c10e feat(demo): Prometheus + Grafana with a provisioned dashboard
make demo-ui now also starts Prometheus (scrapes coordinator:8080/metrics) and
Grafana with a provisioned datasource and a SciMesh Coordinator dashboard
(request rate & p95 by route, status mix, goroutines, RSS). Grafana allows
anonymous viewing so the dashboard opens without a login; admin/admin to edit.

- monitoring/prometheus.yml + grafana provisioning + dashboard JSON
- docker-compose.monitoring.yml overlay (third -f in demo-ui.sh)
- demo prints the Grafana and Prometheus URLs
2026-07-26 21:49:52 +03:00
Efremenko Arhip e584cfc481 feat(coordinator): Prometheus /metrics endpoint
Adds internal/metrics: a private registry with the Go runtime + process
collectors and HTTP RED instrumentation (scimesh_http_requests_total and
request_duration_seconds), labelled by method/status and a normalized route so
per-id paths collapse to {id} and never blow up label cardinality.

/metrics is unauthenticated (like /health) for a Prometheus scraper; the
middleware wraps the whole router so every request is measured once.
2026-07-26 21:47:27 +03:00
Efremenko Arhip 4ac19999a9 feat(coordinator): profile page at /ui/profile
Shows the signed-in user's id, email, role, verified status, and created-at by
proxying the session token to the userservice /me endpoint (email/created_at
are not in the JWT). Profile link added to the dashboard, job, and admin headers.
Tests: /me proxy forwards the bearer and renders the account; redirect without a session.
2026-07-26 21:39:08 +03:00
Efremenko Arhip df4bdc9de9 feat(coordinator): admin panel at /ui/admin
Admin-only page to promote/demote/verify/unverify a user by id. Actions proxy
to the userservice forwarding the admin's session JWT, which the userservice
re-checks (defense in depth). Non-admins are redirected off the panel; the
Admin link shows only for admins. Admins already see all jobs on the dashboard.

Tests: requireAdmin gate, bearer forwarding, action/id validation, admin link.
2026-07-26 21:27:39 +03:00
Efremenko Arhip 9a458ec4ef feat(users): admin promote/demote endpoints
POST /users/{id}/promote and /demote set a user's role (admin/user), admin-only
(403 otherwise). Mirrors the verify endpoints: SetRole use case + repo method,
validated role. Unit, admin-flow, and integration tests included.
2026-07-26 21:14:32 +03:00
Efremenko Arhip 49eb662798 feat(coordinator): logout button in the operator UI
The dashboard and job pages show a 'Log out' control (POST /ui/logout) and a
'Signed in · <role>' label when a userservice session is active. Under basic
auth (no session) neither appears, so the fallback UI is unchanged.

Threads a template-only Session view (json:"-") from authctx into the
dashboard and job views. Tests assert the control renders only in session mode.
2026-07-26 21:03:50 +03:00
Efremenko Arhip 5a9a10c681 feat(demo): bring up coordinator + userservice, seed root admin
make demo-ui now starts the userservice (own Postgres + migrations) beside the
coordinator on a shared JWT secret, and seeds a root admin. The coordinator runs
in UI session mode, so /ui opens a login page instead of a basic-auth prompt.

- docker-compose.users.yml overlay: userservice stack + coordinator JWT wiring
- demo-ui.sh: waits for the userservice, logs in as the seeded admin to poll the
  now session-gated dashboard, and prints the admin credentials
- validated with docker compose config (6 services, merged env)
2026-07-26 20:55:54 +03:00
Emil f5ead0a450 Document team and scaling roadmap 2026-07-26 20:40:25 +03:00
Efremenko Arhip c8c6455caf feat(coordinator): owner-scope UI views by logged-in user
With a UI session, the dashboard and job pages are scoped to the caller:
- Overview lists only the user's own jobs (admin/basic-auth operator: all)
- JobDetail, artifact download, and preview 404 on another user's job
- scoping keys off authctx: no requester (basic auth) still sees everything,
  so the fallback operator UI is unchanged

ListJobs gains an owner filter (SQL WHERE) so paging stays correct per user.
Tests cover Overview scoping and cross-user JobDetail rejection.
2026-07-26 20:12:49 +03:00
Efremenko Arhip 33f629f387 feat(coordinator): UI login/register via userservice (cookie session)
When JWT_SECRET + USERSERVICE_URL are set, the operator UI authenticates
through userservice login/registration instead of the static UI_AUTH_TOKEN:
- /ui/login, /ui/register, /ui/logout pages proxy to the userservice
- successful login stores the JWT in an httpOnly, /ui-scoped cookie
- withUISession verifies the cookie locally and stamps the requester
- unset -> falls back to basic auth, so the team's existing flow is unchanged

Tests cover the session gate, cookie set/clear, and the login/register proxy.
2026-07-26 19:54:39 +03:00
Efremenko Arhip a7e949a0a7 feat(users): bootstrap first admin on startup
BOOTSTRAP_ADMIN_EMAIL/PASSWORD seed a role=admin account at boot if absent —
the only way to get the first admin, since /register makes plain users and
promotion needs an existing admin. Idempotent and race-safe. Tests included.
2026-07-26 19:44:06 +03:00
Efremenko Arhip 163cbe14bf fix(coordinator): bind JWT caller to worker at claim (close quarantine bypass)
The trust tier was read off the caller-supplied worker_id, so a JWT user who
knew any trusted worker's id could claim as it — draining and poisoning the
trusted queue and bypassing the untrusted-worker quarantine entirely.

Claim now requires a JWT caller to own the worker it acts as; a shared-token
caller (lab operator) may still act as any worker. Claim is the sole grantor of
a lease, so this also protects the downstream heartbeat/result/failure paths.

Tests: reject claim as another user's worker; allow claim as own worker.
2026-07-26 19:26:30 +03:00
Efremenko Arhip 80ff72a0fe feat(coordinator): worker trust tiers (C1) — enroll volunteers, quarantine untrusted
- migration 0012: workers.owner_id + trust_level (trusted/untrusted)
- verifier/authctx read the JWT verified claim; IsTrusted() = admin||verified
- /workers/register resolves trust from auth: service token or verified/admin
  JWT -> trusted; plain user JWT -> untrusted, tagged with owner_id
- claim quarantines untrusted workers (no tasks) until quorum (C2) lands
- unit tests for trust resolution, quarantine, and the verified claim

Additive and backward compatible: shared-token workers stay trusted, so the
existing worker flow and team tests are unchanged. Quorum verification (C2)
is deferred.
2026-07-26 19:18:50 +03:00
Efremenko Arhip c6a66747eb feat(users): add admin-granted verified badge for trusted contributors
- migration 0002: users.verified boolean, default false
- verified rides in the JWT (role + verified claims)
- POST /users/{id}/verify + /unverify, admin-only (403 otherwise)
- Issue now takes the whole user so trust claims travel in the token
- unit + integration + admin-flow tests
2026-07-26 19:10:01 +03:00
Efremenko Arhip 0c1f5f06d4 chore(users): remove coordinator leftovers, tidy for userservice
- delete api/requests.http and ARCHITECTURE.md (coordinator content)
- rewrite README.md and .env.example for the userservice
- drop dead RunPeriodic (reaper machinery userservice has no use for)
- fix .gitignore/.dockerignore/.golangci.yml module + artifact names
- degeneralize stale copied comments that said "coordinator"
2026-07-26 16:36:17 +03:00
Efremenko Arhip 67407220c3 ci(users): add pipeline (vet, gofmt, race, lint, migrate, integration)
Mirrors the coordinator workflow against a scimesh_users Postgres service.
Switches the test request helper to http.NewRequestWithContext so the noctx
linter passes on the go1.22 module.
2026-07-26 16:27:24 +03:00
Efremenko Arhip 1b1b971378 fix(coordinator): scan owner_id in UIReadRepo.ListJobs
jobColumns gained owner_id but ListJobs' Scan still read 12 targets,
so the query returned 13 columns and pgx failed at runtime. Only the
integration tests (real DB) caught it; memstore-backed unit tests did not.
2026-07-26 16:24:34 +03:00
Efremenko Arhip 73196579e8 test(users): cover config, usecase error paths, and /me 500 path 2026-07-26 16:15:36 +03:00
Efremenko Arhip a3db1a1e67 add users logic 2026-07-26 14:38:24 +03:00
Emil 16db1e41f7 Link team profiles in README 2026-07-25 15:02:28 +03:00
Emil ad9cc8f95c Add team section to README 2026-07-25 14:54:50 +03:00
Emil 746958884e Document local pipeline demo 2026-07-24 17:37:46 +03:00
Emil 7ad28b939d Show live processing speed chart 2026-07-24 17:28:16 +03:00
Emil 1012f5d95a Allow demo worker count override 2026-07-24 17:21:45 +03:00
Emil f5baec507c Add final result preview button 2026-07-24 17:19:31 +03:00
Emil 5a1414bee9 Add local pipeline demo launcher 2026-07-24 17:13:26 +03:00
Emil 8b738efd5d Add safe artifact previews 2026-07-24 17:04:07 +03:00
Emil d0aeb7fc95 Upgrade pipeline observability UI
coordinator / test (push) Waiting to run
2026-07-24 15:13:59 +03:00
Emil a055473706 Add final result reduction
coordinator / test (push) Waiting to run
2026-07-24 14:58:18 +03:00
Emil 6e67daa9eb Merge distributed similarity search 2026-07-24 14:43:57 +03:00
Emil 0f3a2d92d8 Add distributed similarity search
python / test (push) Waiting to run
2026-07-24 14:38:11 +03:00
Emil 0bef7604fd Merge distributed workload protocol 2026-07-24 14:26:58 +03:00
Emil 6ef92908a1 Add distributed workload protocol
python / test (push) Waiting to run
2026-07-24 14:25:38 +03:00
Emil f953112cfd Merge distributed pipeline hardening 2026-07-24 14:16:42 +03:00
Emil 19cbf7f113 Harden distributed pipeline 2026-07-24 14:16:42 +03:00
Emil 9ec8f50313 Fix worker interruption handling 2026-07-24 13:19:40 +03:00
Emil 08f5478a66 Define distributed workload protocol 2026-07-24 13:09:25 +03:00
Emil bde6cdb4ba Improve worker lifecycle controls 2026-07-24 13:08:10 +03:00
Emil 43ceec1f77 Add two-worker smoke test 2026-07-24 13:05:34 +03:00
Emil f5b16b057f Add UI development task briefs 2026-07-24 00:13:55 +03:00
Emil f8de0b2b9d Merge feat/web-interface 2026-07-23 23:43:57 +03:00
Emil 7547a30bde Add job cancellation and dataset row limit
coordinator / test (push) Waiting to run
2026-07-23 23:14:25 +03:00
Emil 6bac7dad3c Clarify worker failures in operator UI 2026-07-23 22:59:51 +03:00
Emil c7956c4683 Fix relative worker work directory 2026-07-23 22:48:27 +03:00
Emil d648beede2 Use English operator UI copy 2026-07-23 22:45:36 +03:00
Emil ac9b921401 Clarify operator UI workflow 2026-07-23 22:41:14 +03:00
Emil 5be87ad762 Fix UI dataset upload field order 2026-07-23 22:20:18 +03:00
Emil e83e0b5e1f Add local operator web interface 2026-07-23 22:08:58 +03:00
Emil ec861edce5 Plan initial web interface 2026-07-23 21:53:00 +03:00
Emil 2ce9687e52 Merge branch 'fix/worker-contract-hardening'
# Conflicts:
#	docs/api-contract.md
#	scimesh/worker/artifacts.py
#	scimesh/worker/coordinator.py
#	scimesh/worker/daemon.py
#	scimesh/worker/models.py
#	tests/test_worker_daemon.py
2026-07-23 21:47:26 +03:00
Emil 66836b962d Merge branch 'fix/coordinator-worker-integration' 2026-07-23 21:47:13 +03:00
Emil 484ecd0dfa Bind result artifacts to lease attempts 2026-07-23 21:44:44 +03:00
Emil 983c5843ec Fix coordinator worker integration 2026-07-23 21:33:37 +03:00
Emil b4a89dd7c2 Merge branch 'feat/coordinator'
# Conflicts:
#	docs/api-contract.md
2026-07-23 21:17:53 +03:00
Efremenko Arhip 8af8ddcf48 build: add make check — run the whole suite with one command
vet + lint + race unit tests, then bring up the stack and run the integration
suite and the end-to-end smoke test. One command for a reviewer to verify
everything.
2026-07-23 21:12:32 +03:00
Efremenko Arhip d271170dd2 docs: task brief for the worker-side agent
A self-contained handoff for whoever (human or agent) implements the Python
worker: what the coordinator already does, where the contract lives
(openapi.yaml, building-workers.md), how to run it locally, the deliverable
(CTX-06), acceptance criteria, and the do-not-break rules.
2026-07-23 21:06:16 +03:00
Emil e0ee95cbab Harden worker contract and transport 2026-07-23 20:49:50 +03:00
Efremenko Arhip 6829632651 ci+docs: GitHub Actions pipeline and a worker-author guide
- .github/workflows/coordinator.yml: vet, gofmt, race unit tests, golangci-lint,
  and the integration suite against a Postgres service — on every push/PR that
  touches coordinator/.
- docs/building-workers.md: a from-scratch guide for implementing a worker (the
  claim/heartbeat/upload/complete loop, auth, lease semantics, status codes,
  and the do-not-break rules), pointing at openapi.yaml for client generation.
- README: refresh the endpoint table, status, and test sections to match reality.
2026-07-23 17:36:26 +03:00
Efremenko Arhip 3b41455b20 feat(coordinator): running state, worker liveness, request-size limits
Polish pass hardening the queue and closing plan gaps.

- Task state machine gains `running`: the first heartbeat moves a task from
  leased to running (migrations 0006/0007 add the enum value and extend the
  lease-integrity check). verifyLease, ExpireLease, the reaper SQL, and job
  progress all treat leased and running alike.
- Worker liveness: a heartbeat from a registered worker (UUID worker_id) bumps
  its last_heartbeat_at online; a second background reaper marks workers offline
  after WORKER_OFFLINE_AFTER of silence (RunReaper generalized to RunPeriodic).
- Request-size limits: JSON bodies capped at 1 MiB; dataset/artifact uploads
  capped at MAX_UPLOAD_BYTES (default 1 GiB) via http.MaxBytesReader.
- Tests cover the running transition, liveness + offline reaper (unit over
  memstore and integration over Postgres).
2026-07-23 17:36:26 +03:00
Efremenko Arhip 4fc3c69fdf docs: add OpenAPI 3.0 spec for the Python worker side
docs/openapi.yaml is a machine-readable mirror of the api-contract, covering
every coordinator endpoint with request/response schemas, auth, and error
codes. The Python author can generate a typed client or Pydantic models from it.
Its 12 paths match the router exactly; all $refs resolve.
2026-07-23 16:58:49 +03:00
Efremenko Arhip e5ba27951a test(coordinator): unit + integration coverage across every layer
- domain: NewJobWithTasks, DeriveStatus, NewUploadedJob, NewShardTask,
  NewWorker, NewArtifact/SetContent (domain 47% -> 88%).
- internal/memstore: in-memory implementations of every usecase port, so
  orchestration can be tested without Postgres or a filesystem.
- usecase: claim/renew/complete/fail/create/register/upload/submit-dataset
  flows over memstore, including rule-10 cross-task rejection, idempotent
  replay, lease sweep-on-claim, and dataset chunking (usecase 0% -> 73%).
- transport: httptest end-to-end over real use cases + memstore — auth,
  readiness, full lifecycle, multipart upload + shard input, error mappings
  (0% -> 70%).
- postgres integration: fix the tests broken by the artifact_id switch and add
  worker-repo, artifact-repo, and shard-task (nullable input_uri) round-trips.

go test -race ./... is clean; golangci-lint (incl. integration tag) reports 0.
2026-07-23 16:47:23 +03:00
Efremenko Arhip c3243a6b7e feat(coordinator): upload a dataset and chunk it into shard tasks (CTX-05, part 4)
The coordinator can now ingest a dataset itself, not only accept client-supplied
chunk URIs.

- internal/chunk: a deterministic, generic TSV row splitter — repeats the header
  per shard, buffers one shard at a time, rejects header-only input. Unit-tested.
- POST /jobs/upload (multipart): streams the dataset into an input artifact,
  splits it into shard artifacts, and creates one shard task per shard, all in
  one transaction; blobs are cleaned up if the transaction fails.
- GET /tasks/{id}/input streams a task's input shard back to the worker.
- domain: NewUploadedJob, NewShardTask, Task/Job.InputArtifactID; a shard task's
  input is an artifact, not a URI. Claim response nests input:{uri,sha256} per
  the contract, with uri = /tasks/{id}/input for shards.
- migration 0005 makes input_uri nullable and adds a has-input check.
- The existing URI-based POST /jobs path is untouched; both coexist.
2026-07-23 16:34:04 +03:00
Efremenko Arhip 4a092d2e4e fix(coordinator): make artifact/log storage writable by the non-root container
The container runs as uid 10001, but the ./data and ./logs bind mounts were
root-owned, so blob storage failed with "mkdir .staging: permission denied"
and the coordinator crash-looped.

Pre-create the storage and log directories in the image owned by the
coordinator user, and switch the bind mounts to named volumes, which inherit
that ownership. The process can now write to them without running as root.
2026-07-23 15:54:21 +03:00
Efremenko Arhip 58da6ef139 feat(coordinator): complete tasks with an artifact_id, not a URI (CTX-05, part 3)
Task results are now coordinator-owned artifacts end to end.

- domain.Task carries ResultArtifactID instead of ResultURI/ResultSHA256;
  CompleteWith and its idempotency key are keyed on the artifact id.
- CompleteTask verifies the referenced artifact was stored for this exact
  task (rule 10): a worker cannot finish task B with task A's artifact, nor
  name an id that isn't a partial_result. Mismatch → 409.
- POST /tasks/{id}/result takes {result:{artifact_id,...}}; ListResults and
  ResultManifest follow.
- migration 0004 drops result_uri/result_sha256 and requires a completed task
  to reference its result_artifact_id.
- smoke and requests.http exercise upload → complete-by-id → replay → conflict.
2026-07-23 15:54:11 +03:00
Efremenko Arhip 6d45406ee0 feat(coordinator): artifact upload/download endpoints (CTX-05, part 2)
Wire the artifact storage foundation to HTTP.

- PUT /tasks/{id}/artifacts/{filename}: a worker streams a partial result;
  the coordinator verifies lease ownership (foreign worker → 409), streams
  the bytes to blob storage while hashing, and records the metadata. An
  orphaned blob from a failed metadata insert is cleaned up.
- GET /artifacts/{id}/download: streams an artifact back with its content
  type, length, and checksum.
- Ownership is read with a new non-locking TaskRepository.Get, so no row lock
  is held across a long upload. Identity travels in X-Worker-ID / X-Task-Attempt
  headers per the contract; upload/download bypass the short request timeout.
- docker-compose mounts ./data for durable artifact storage; smoke and
  requests.http exercise an upload → foreign-409 → download round-trip.
2026-07-23 14:12:18 +03:00
Efremenko Arhip dbf578c500 feat(coordinator): artifact storage foundation (CTX-05, part 1)
Introduce durable, coordinator-owned artifacts — the model the master plan
requires instead of trusting worker-supplied result URIs.

- migration 0003: artifacts table + artifact_kind enum, plus nullable
  input/result_artifact_id columns on jobs and tasks.
- domain.Artifact with NewArtifact/SetContent; storage keys derive from a
  fresh UUID, never from a client filename (no path traversal).
- BlobStore and ArtifactRepository ports.
- blob.FSStore: filesystem blob storage that streams while hashing (SHA-256),
  fsyncs, and atomically renames into place — a failed upload leaves neither a
  committed artifact nor a staging file. Covered by unit tests.
- ArtifactRepo (squirrel) and COORDINATOR_STORAGE_DIR config.

HTTP upload/download handlers and the switch of result submission to
artifact_id come in the next parts.
2026-07-23 14:06:19 +03:00
Efremenko Arhip a5945f2d38 refactor(coordinator): build SQL with squirrel instead of raw strings
Replace the positional-placeholder SQL in the repositories with the
Masterminds/squirrel builder, so column lists and $N numbering are no
longer maintained by hand. The optimistic-lock guard on task Update is now
a readable Where(id, version-1) instead of a $13-1 expression.

Two genuinely set-based statements stay as raw SQL on purpose — claimNext
(a FOR UPDATE SKIP LOCKED CTE) and expireLeases (CASE logic in the SET) —
because a builder cannot express them more clearly.
2026-07-23 13:58:39 +03:00
Efremenko Arhip dc92121acc feat(coordinator): worker registry, API contract, logging & DB retry
Align the coordinator with the master PLAN.md (CTX-00, CTX-04) and harden
process startup.

- CTX-00: freeze docs/api-contract.md as the v1 source of truth for the
  Go coordinator and Python worker.
- CTX-04: worker registry — workers table (migration 0002), domain.Worker,
  RegisterWorker use case, WorkerRepository, and POST /workers/register.
- Contract alignment: claim uses `capabilities` (was `workloads`),
  COORDINATOR_TOKEN env (WORKER_AUTH_TOKEN kept as fallback), and
  GET /health now reports database readiness (503 when the DB is down).
- Logging: logs are teed to stdout and an optional rotated file (LOG_FILE)
  via lumberjack, so they survive a container rebuild.
- Startup resilience: the initial DB connection is retried with backoff,
  so the coordinator waits for Postgres to boot instead of crash-looping.
2026-07-23 13:45:31 +03:00
Efremenko Arhip 5d6390fd98 fix(coordinator): stop masking 501 as an internal error
Unimplemented endpoints answered {"error":"internal error"} because the
detail-hiding branch covered everything >= 500, and 501 falls in that range.
That is actively misleading: it sent one debugging session looking for a
database fault when the endpoint simply had no implementation yet.

501 now says "not implemented". It leaks nothing — the client already knows the
status code — while genuine 5xx responses keep hiding their details behind a
request id.
2026-07-22 14:59:15 +03:00
Efremenko Arhip 6517145622 chore(coordinator): add API smoke script and request collection
Two ways to exercise every endpoint, both living in the repo rather than in a
personal Postman workspace:

- scripts/smoke.sh walks the full lifecycle and asserts each status, exiting
  non-zero on the first surprise, so it works in CI as well as by hand;
- api/requests.http drives the same calls from an editor's REST client, with
  later requests reusing ids captured from earlier responses. It doubles as
  API documentation for the worker author.

The script claims until it sees its own job's chunks instead of assuming an
empty queue: a shared development database usually holds pending tasks from
earlier runs, and it takes the attempt number from the claim response, since a
task requeued after an expired lease comes back with attempt 2 or 3.

Note for whoever extends the validation cases: Go matches JSON field names
case-insensitively, so "worker_ID" is accepted as "worker_id". Only a genuinely
unknown key trips DisallowUnknownFields.
2026-07-22 14:53:27 +03:00
Efremenko Arhip f1c3163be4 feat(coordinator): implement PostgreSQL repositories
Replaces the repository stubs with real pgx queries, so the queue now works end
to end: a job is split into tasks, leased to workers one at a time, heartbeated,
completed, and reflected in job progress.

Task claiming is a single statement — SELECT ... FOR UPDATE SKIP LOCKED feeding
an UPDATE — so concurrent coordinators lease different rows instead of blocking
on the same one. Writes use optimistic concurrency: the entity increments its
version in memory, and the UPDATE guards on the previous value.

Retries moved to the transaction level. Once Postgres aborts a transaction with
a serialization failure, replaying one statement inside it cannot help; the unit
of retry is Begin -> fn -> Commit, which is safe because each attempt re-reads
its rows through GetForUpdate.

Adds integration tests behind the `integration` build tag, run against a real
PostgreSQL through TEST_DATABASE_URL: concurrent claiming hands each task to
exactly one worker, job creation rolls back whole, stale writes are refused,
completed results keep chunk order, and expired leases return to the queue.

Two bugs the tests caught:

- a nil parameters map reached a NOT NULL jsonb column as SQL NULL, since pgx
  sends NULL rather than omitting the column and letting DEFAULT '{}' apply;
- replaying an already-recorded result returned 409. The idempotent path leaves
  the entity untouched, so the version guard matched nothing and a successful
  no-op looked like a conflict. CompleteTask now skips the write when the
  entity did not change.
2026-07-22 14:48:58 +03:00
Efremenko Arhip bda22666d7 feat(coordinator): scaffold task-queue service in Go
Adds the SciMesh coordinator: a durable task-queue server on PostgreSQL
that owns all database access, with workers reaching it over HTTP only.

Structured as a modular monolith following Clean Architecture:

  domain     entities and their invariants, no I/O
  usecase    business operations + repository/clock ports
  transport  HTTP handlers, DTOs, auth, error mapping
  storage    PostgreSQL repositories, transactions carried in context
  infra      config, pool, clock, server, lease reaper

Dependencies point strictly inward; domain imports nothing from the module.

Working: layer wiring, routing, shared-token auth, access logging, request
IDs, domain-error to status-code mapping, transactional boundaries,
graceful shutdown (HTTP drain -> reaper stop -> pool close), migrations,
and a Compose stack starting Postgres -> migrations -> coordinator.

The domain is complete and covered by unit tests that need no database:
lease ownership, stale attempts, idempotent result replay, retry budgets,
and lease expiry.

Repository methods are stubs returning ErrNotImplemented (HTTP 501). The
SQL for atomic claiming (FOR UPDATE SKIP LOCKED) and for lease expiry is
written and ready to wire up.

See coordinator/ARCHITECTURE.md for the layer map and a request traced
through every layer.
2026-07-22 13:49:01 +03:00
412 changed files with 59852 additions and 867 deletions
+66
View File
@@ -0,0 +1,66 @@
name: coordinator
on:
push:
paths:
- "coordinator/**"
- ".github/workflows/coordinator.yml"
pull_request:
paths:
- "coordinator/**"
- ".github/workflows/coordinator.yml"
defaults:
run:
working-directory: coordinator
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_USER: scimesh
POSTGRES_PASSWORD: scimesh
POSTGRES_DB: scimesh
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U scimesh"
--health-interval 5s
--health-timeout 3s
--health-retries 10
env:
TEST_DATABASE_URL: postgres://scimesh:scimesh@localhost:5432/scimesh?sslmode=disable
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: coordinator/go.mod
cache-dependency-path: coordinator/go.sum
- name: go vet
run: go vet ./...
- name: gofmt
run: test -z "$(gofmt -l .)" || (gofmt -l . && exit 1)
- name: unit tests (race)
run: go test -race ./...
- name: lint
run: go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.2 run --build-tags=integration ./...
- name: install migrate CLI
run: go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@v4.17.1
- name: apply migrations
run: migrate -path internal/storage/postgres/migrations -database "$TEST_DATABASE_URL" up
- name: integration tests
run: go test -tags=integration ./internal/storage/postgres/ -v
+28
View File
@@ -0,0 +1,28 @@
name: python
on:
push:
paths:
- "scimesh/**"
- "tests/**"
- "pyproject.toml"
- ".github/workflows/python.yml"
pull_request:
paths:
- "scimesh/**"
- "tests/**"
- "pyproject.toml"
- ".github/workflows/python.yml"
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
cache: pip
- run: python -m pip install --upgrade pip
- run: python -m pip install -e '.[dev]'
- run: pytest -q
+150
View File
@@ -0,0 +1,150 @@
name: release
# Builds static coordinator and worker-agent binaries for every major
# platform on a v* tag push, attaches them (plus SHA-256 checksums) to the
# GitHub Release, and pushes the coordinator image to GHCR.
#
# git tag v1.0.0 && git push origin v1.0.0
on:
push:
tags: ["v*"]
permissions:
contents: write
packages: write
jobs:
binaries:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
os: [linux, darwin, windows]
arch: [amd64, arm64]
defaults:
run:
working-directory: coordinator
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: coordinator/go.mod
cache-dependency-path: coordinator/go.sum
- name: vet
run: go vet ./...
- name: build coordinator and worker-agent
env:
VERSION: ${{ github.ref_name }}
run: |
mkdir -p dist
for cmd in coordinator worker-agent; do
CGO_ENABLED=0 GOOS=${{ matrix.os }} GOARCH=${{ matrix.arch }} \
go build -trimpath \
-ldflags="-s -w -X main.version=${VERSION#v}" \
-o "dist/${cmd}-${{ matrix.os }}-${{ matrix.arch }}${{ matrix.os == 'windows' && '.exe' || '' }}" \
"./cmd/${cmd}"
done
- uses: actions/upload-artifact@v4
with:
name: binaries-${{ matrix.os }}-${{ matrix.arch }}
path: coordinator/dist/*
if-no-files-found: error
wheel:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: build the scimesh wheel
env:
VERSION: ${{ github.ref_name }}
run: |
# The tag (v1.1.0-alpha.10) becomes the package version in its
# PEP 440 form (1.1.0a10); the wheel is then version-locked to the
# binaries of the same release.
WHEEL_VERSION="${VERSION#v}"
WHEEL_VERSION="${WHEEL_VERSION/-alpha./a}"
WHEEL_VERSION="${WHEEL_VERSION/-beta./b}"
WHEEL_VERSION="${WHEEL_VERSION/-rc./rc}"
sed -i "s/^version = .*/version = \"${WHEEL_VERSION}\"/" pyproject.toml
python -m pip install --quiet build
python -m build --wheel --outdir dist
ls -la dist/
- uses: actions/upload-artifact@v4
with:
name: wheel
path: dist/*.whl
if-no-files-found: error
release:
needs: [binaries, wheel]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
path: artifacts
# Only the binary artifacts: the image job also uploads a buildkit
# cache artifact (*.dockerbuild) that download-artifact cannot fetch.
pattern: binaries-*
merge-multiple: true
- uses: actions/download-artifact@v4
with:
name: wheel
path: artifacts
- name: checksums
working-directory: artifacts
run: sha256sum * > SHA256SUMS.txt
- uses: softprops/action-gh-release@v2
with:
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:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/metadata-action@v5
id: meta
with:
images: ghcr.io/${{ github.repository }}/coordinator
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,value=latest
- uses: docker/build-push-action@v6
with:
context: coordinator
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
VERSION=${{ github.ref_name }}
+66
View File
@@ -0,0 +1,66 @@
name: users
on:
push:
paths:
- "users/**"
- ".github/workflows/users.yml"
pull_request:
paths:
- "users/**"
- ".github/workflows/users.yml"
defaults:
run:
working-directory: users
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_USER: scimesh
POSTGRES_PASSWORD: scimesh
POSTGRES_DB: scimesh_users
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U scimesh"
--health-interval 5s
--health-timeout 3s
--health-retries 10
env:
TEST_DATABASE_URL: postgres://scimesh:scimesh@localhost:5432/scimesh_users?sslmode=disable
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: users/go.mod
cache-dependency-path: users/go.sum
- name: go vet
run: go vet ./...
- name: gofmt
run: test -z "$(gofmt -l .)" || (gofmt -l . && exit 1)
- name: unit tests (race)
run: go test -race ./...
- name: lint
run: go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.2 run --build-tags=integration ./...
- name: install migrate CLI
run: go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@v4.17.1
- name: apply migrations
run: migrate -path migrations -database "$TEST_DATABASE_URL" up
- name: integration tests
run: go test -tags=integration ./internal/storage/postgres/ -v
+7
View File
@@ -11,3 +11,10 @@ results/
*_similarities.csv
test_results.csv
test_structures/
# Local coordinator-worker execution state
worker-data*/
scimesh-worker-data/
coordinator/.demo/
site/
coordinator/bin/
+22
View File
@@ -0,0 +1,22 @@
# Session Goal
COMPLETED
выполни полностью намеченный сейчас план. автономно
## Plan
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
- ВСЕ ФАЗЫ ВЫПОЛНЕНЫ И ЗАПУШЕНЫ:
- 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.
+4 -2
View File
@@ -5,8 +5,10 @@
SciMesh is a Python package for molecular-similarity workloads. Source lives in
`scimesh/`: `chemistry/` reads data and makes fingerprints, `workloads/`
contains commands, and `core/` provides the workload protocol and registry.
The worker daemon in `scimesh/worker/` is a coordinator client, not a database
client. Tests are in `tests/`; specifications in `docs/`; roadmap: `PLAN.md`.
The Go worker agent (`coordinator/internal/agent/`) is a coordinator client,
not a database client; the Python side of a claimed task lives in
`scimesh/worker/` (the per-task SDK execution entry). Tests are in `tests/`;
specifications in `docs/`; roadmap: `PLAN.md`.
For distributed work, read `.agents/`, `docs/api-contract.md`,
and `STATUS.md`. Use one CTX task per pull request; local workloads are the
+46
View File
@@ -0,0 +1,46 @@
.DEFAULT_GOAL := help
.PHONY: help agent coordinator demo-ui demo-down demo-logs smoke-two-worker docs docs-serve
help:
@printf '%s\n' \
'SciMesh developer commands:' \
' make agent Build the Go worker agent (coordinator/bin/worker-agent).' \
' make coordinator Build the coordinator server as a static binary' \
' (coordinator/bin/coordinator).' \
' make demo-ui Start the local UI pipeline demo with 2 Go worker agents.' \
' make demo-ui WORKERS=3 Start the demo with 3 workers.' \
' make demo-logs Follow coordinator logs for the demo.' \
' make demo-down Stop demo containers and workers.' \
' make smoke-two-worker E2E: two Go agents process 4 shards and the' \
' result must match the local CLI reference.' \
' make docs Build the MkDocs site into site/.' \
' make docs-serve Serve the MkDocs site at http://localhost:8000.' \
'' \
'After make demo-ui: open http://localhost:18080/ui (operator / demo-ui-secret).'
# Convenient entry points from the repository root. Extra settings are passed
# through, for example: make demo-ui WORKERS=3
agent:
$(MAKE) -C coordinator agent
coordinator:
$(MAKE) -C coordinator coordinator
demo-ui:
$(MAKE) -C coordinator demo-ui
demo-down:
$(MAKE) -C coordinator demo-down
demo-logs:
$(MAKE) -C coordinator demo-logs
smoke-two-worker:
./scripts/two-worker-smoke.sh
docs:
.venv/bin/mkdocs build
docs-serve:
.venv/bin/mkdocs serve
+308 -16
View File
@@ -5,11 +5,13 @@
> platform. It is intentionally detailed enough to split into independent task
> briefs for developers or coding agents.
>
> **Planning baseline.** This branch starts from `Workers`: the Python package
> has local `similarity-search` and `similarity-graph` workloads plus a Worker
> Daemon client. The coordinator and PostgreSQL implementation do not yet
> exist. The Worker contract and the Go/PostgreSQL design briefs in `docs/` are
> part of this plan.
> **Current planning baseline (2026-08-01).** The Go/PostgreSQL coordinator,
> Python Worker Agent, versioned distributed-workload protocol, artifact-backed
> task lifecycle, reducer orchestration, operator UI, User Service, and
> distributed `similarity-search` are implemented on `main`. The evidence-based
> completion tracker is [`STATUS.md`](STATUS.md); this document defines the
> remaining direction and dependencies. Earlier descriptions of a missing
> coordinator are historical context, not current work.
---
@@ -51,7 +53,7 @@ Coordinator reducer -> final artifact -> download/status API
### 2.1 In scope
- Go 1.22+ coordinator service with PostgreSQL 15+;
- Go 1.25+ coordinator service with PostgreSQL 15+;
- Python Worker Daemon running existing SciMesh workloads locally;
- durable job, task, worker, and artifact metadata;
- local coordinator-managed artifact storage for the first deployment;
@@ -66,7 +68,8 @@ Coordinator reducer -> final artifact -> download/status API
- cloud object storage, Kubernetes, autoscaling, and multi-region operation;
- arbitrary shell commands sent by coordinator to workers;
- user accounts, multi-tenancy, billing, or sophisticated authorization;
- billing and sophisticated multi-tenant administration beyond the implemented
User Service and owner scoping;
- GPU scheduling and multiprocessing inside a worker;
- Docker as a required runtime dependency;
- video/CV processing implementation;
@@ -490,14 +493,84 @@ brute-force graph for both `greater` and `less` threshold directions.
### 7.3 Future workload policy
A new workload is accepted only when it supplies:
A workload is more than a runner. It must define validation, planner and
reducer behavior, worker allowlist/capabilities, input and output artifacts,
UI/API parameters, reproducible execution environment, result verification,
golden cross-worker fixtures, and applicable resource limits. The future public
contract is described in [`docs/scimesh-sdk-roadmap.md`](docs/scimesh-sdk-roadmap.md).
Its normative future interfaces and execution semantics are defined in the
design-draft [`docs/scimesh-sdk-contract.md`](docs/scimesh-sdk-contract.md).
- an input/parameter validator;
- an explicit sharding strategy;
- bounded-memory task execution;
- deterministic reduction semantics;
- fixture-based local and distributed correctness tests;
- a `describe()` payload for UI/API discovery.
Every workload declaration must classify its task decomposition and input/output
artifact shapes, determinism, reduction semantics, verifier mode, supported
trust modes, CPU/memory/accelerator needs, and maximum output growth. The
initial profiles are:
| Profile | Current acceptance policy |
| --- | --- |
| Byte-exact deterministic | Supported for untrusted quorum when whole artifacts have identical SHA-256. |
| Canonical-exact deterministic | Deferred until the parser, schema, ordering, encoding, and serializer are versioned. |
| Numeric deterministic with tolerance | Deferred until structured numeric comparison exists. |
| Stochastic/search-based | Requires domain-specific evidence, repeated runs, or trusted execution. |
| Trusted-only or domain-verified | May be planned only with an explicit trust policy and verifier. |
The current untrusted quorum records one vote per owner and accepts a task only
when distinct owners upload artifacts with the same complete-file SHA-256. It
therefore supports only the byte-exact profile (or a workload that first makes
its output byte-identical through a specified canonicalization step). Reducers
must fail safely rather than silently merge inconsistent partial results.
Before a workload is admitted to untrusted execution it needs a reproducibility
gate: pinned environment/container digest and dependency versions; fixed locale,
timezone, UTF-8/newline/CSV settings; explicit invalid-row and algorithm
options; canonical representation and ordering; deterministic filenames/archive
metadata; golden fixtures from two independently provisioned workers; local vs
distributed parity; and retry/out-of-order completion tests. A loose dependency
constraint is insufficient for byte-exact quorum.
Near-term critical path:
```text
distributed similarity-graph
-> reliability, security, and cross-language CI
-> stable first release
-> SDK foundation and descriptor-batch
-> additional deterministic workloads
```
Initial deterministic-workload backlog: `descriptor-batch` (the first SDK
reference workload), molecule standardization, SMARTS screening, fingerprint
export, fixed-template SMIRKS enumeration with strict caps, and reaction
validation/descriptors. `similarity-graph` remains ahead of this backlog.
Bounded combinatorial libraries and seeded conformers need specialized controls.
ML, retrosynthesis, docking, QM, molecular dynamics, and GPU workloads are
deferred until verifier/trust and reproducibility requirements are met.
### 7.4 Future verification, concurrency, and accelerators
Verification is a future versioned workload capability, not permanent
whole-file-SHA logic. Planned modes are `ExactArtifactVerifier`,
`CanonicalRecordVerifier`, `NumericToleranceVerifier`, `DomainSpecificVerifier`,
and `TrustedWorkerPolicy`. Exact SHA-256 remains the first and safest mode;
canonical and numeric modes must compare bounded structured data and publish
sanitized evidence and failure reasons.
Worker concurrency remains **1** until implemented and tested. Its target model
is one physical machine running one Worker Agent with `N` execution slots and
one isolated subprocess per active Task, rather than one registered worker per
CPU core. `max_concurrency` must be separate from `cpu_count`; each task keeps
its own heartbeat, attempt directory, lease lifecycle, resource request, and
graceful-drain behavior. CPU-bound scientific code should use processes and
avoid nested oversubscription.
Accelerator support is also deferred. The coordinator matches generic resource
requirements; the Worker Agent discovers and isolates devices (including
`CUDA_VISIBLE_DEVICES`) and owns process/accounting lifecycle; the Python
workload owns batching, memory strategy, deterministic output, and scientific
validation; reducers/verifiers define CPU/GPU-independent semantics. CUDA and
scientific kernels do not belong in the Go coordinator. GPU work follows stable
CPU slices, generic resource requirements, pinned worker images, and tested
CPU/GPU or domain-valid equivalence.
---
@@ -780,6 +853,10 @@ for the exact sparse similarity graph.
**Goal:** Add a small server-rendered or static HTML UI to inspect jobs, tasks,
workers, and download final artifacts.
**Detailed delivery plan:** [`docs/web-interface-plan.md`](docs/web-interface-plan.md).
The plan deliberately starts with a clearly labelled diagnostic UI before
CTX-09 enables final result downloads.
**Depends on:** CTX-04, CTX-09.
**Acceptance criteria:**
@@ -816,6 +893,212 @@ workers, and download final artifacts.
- failure/retry scenarios have automated coverage;
- README contains architecture diagram, security caveat, and troubleshooting.
### CTX-13 — In-worker CPU parallelism
**Goal:** Allow a worker to use a bounded, configured number of CPU threads or
processes while preserving the existing one-task-per-lease coordinator model.
**Depends on:** CTX-12.
**Acceptance criteria:**
- worker concurrency is an explicit configuration value with a safe default of
one;
- a task's internal parallel execution has bounded memory and does not build a
dense N×N similarity matrix;
- CPU-parallel `similarity-search` and `similarity-graph` outputs match the
single-threaded local reference byte-for-byte where ordering is observable;
- result ordering is deterministic across worker counts and block sizes;
- cancellation, lease loss, and worker failure stop child work safely and do
not report a successful result;
- benchmarks and tests cover one-worker and multi-worker configurations.
### CTX-14 — GPU-accelerated workload execution
**Goal:** Add an optional GPU execution backend for supported molecular
workloads, while retaining the validated CPU implementation as the reference
and fallback.
**Depends on:** CTX-13.
**Acceptance criteria:**
- GPU capability and backend version are advertised explicitly by a worker;
- the coordinator schedules GPU work only to compatible workers and CPU-only
workers continue to claim CPU tasks;
- unsupported hardware, unavailable drivers, and GPU execution errors produce
sanitized failures or a documented CPU fallback;
- GPU results match the CPU reference within a documented, tested numerical
tolerance and preserve deterministic output ordering;
- GPU memory use is bounded and no dense N×N similarity matrix is created;
- CPU-only CI verifies backend selection and contract behavior, with GPU
integration tests documented for compatible runners.
### CTX-15 — User Service and access control
**Goal:** Introduce a dedicated User Service for user identity and access
control, without coupling workers to user credentials or moving scientific
workload logic into the service.
**Depends on:** CTX-12.
**Acceptance criteria:**
- the service has a versioned, documented API in
[`docs/user-service-api-contract.md`](docs/user-service-api-contract.md) and
owns user identity data;
- credentials and authentication tokens are stored and handled securely; they
are never logged or exposed to workers;
- authenticated identity is propagated to coordinator requests through an
explicit, validated boundary;
- authorization restricts access to jobs and artifacts to the intended user or
project;
- unauthenticated, expired-token, and cross-user access attempts have
automated failure tests;
- the existing single-operator demo remains usable through a documented local
development configuration.
### CTX-16 — Workload SDK foundation
**Goal:** Provide a strict Python authoring SDK for installed, allowlisted
scientific workloads while retaining the CTX-07 distributed protocol as a
compatible wire profile.
**Depends on:** CTX-07 and CTX-08. Coordinator-backed generalized scheduling
also depends on CTX-10 through CTX-14, but the Python contract and local
conformance runtime can land independently and must fail closed for unavailable
features.
**Acceptance criteria:**
- public manifest, workflow, task, artifact, resource, execution, provenance,
and verifier value objects are immutable, typed, JSON-safe, versioned, and
strict about unknown fields;
- installed workload discovery requires an administrator allowlist plus exact
workload version and package digest; job parameters cannot select code;
- compatibility negotiation covers SDK/protocol/profile/feature/environment
versions and occurs before planner invocation;
- plans/tasks pin package and manifest digests plus selected trust mode, and
quorum candidates carry coordinator-owned candidate/owner and scientific
binding identities;
- `core-batch-v1` has a trusted local conformance executor with atomic resource
reservation, sealed-output/provenance validation, declared verifier
invocation, and golden scientific parity;
- exact, canonical-record, and structured numeric-tolerance verifier
primitives return bounded sanitized decisions;
- the existing distributed `similarity-search` is available through an adapter
without changing its wire schema, worker alias boundary, or scientific
result, and parity is tested;
- advanced dynamic, stream, accelerator, gang, and side-effect profiles are
rejected unless an enforcing runtime advertises their required features;
- an author guide documents package entry points, security boundaries,
conformance tests, and current coordinator/Worker limitations.
---
### CTX-17 — Self-provisioning coordinator and setup wizard
**Goal:** A downloaded coordinator binary should bring up a working platform
with as little manual configuration as possible: it provisions its own
schema, and a `setup` command walks the operator through the remaining
environment (database creation, secrets, admin account).
**Depends on:** the Go coordinator and the release build pipeline. 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:**
- the binary embeds the migrations and applies pending ones on startup by
default (`AUTO_MIGRATE=false` opts out for managed databases); applying is
idempotent and safe under concurrent starts;
- `coordinator setup` (interactive, then non-interactive with `--yes`) checks
database reachability, offers to create the role/database when credentials
allow it, applies migrations, writes a `.env` with a generated `JWT_SECRET`
and storage path, and prints exact next steps;
- `coordinator --version` and the setup output agree on the release build;
- the wizard explains what it cannot do itself: running PostgreSQL and the
userservice, with concrete commands (docker compose, systemd) to finish;
- the Docker image keeps working without the separate migrate step, and the
release workflow publishes the binaries that support `setup`;
- setup fails closed on non-interactive input and never logs secrets.
---
### 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
@@ -831,6 +1114,7 @@ parallel unless one engineer owns integration.
| Distributed computation | CTX-07, CTX-08, CTX-10 | Scientific Python engineer |
| Product surface | CTX-09, CTX-11 | Full-stack/backend engineer |
| Quality gate | CTX-12 | DevOps/QA engineer |
| Workload SDK | CTX-16 | Scientific Python/platform engineer |
Suggested order for a small team:
@@ -944,15 +1228,23 @@ Before merging a task, reviewer checks:
Do not start these before CTX-12 is accepted.
- Replace local artifact storage with S3/MinIO behind an `ArtifactStore` API.
- Add worker labels/capacity-aware scheduling and concurrency > 1.
- Add worker labels and capacity-aware scheduling.
- Implement CTX-13 for bounded in-worker CPU parallelism.
- Implement CTX-14 for optional GPU-accelerated workload execution.
- Implement CTX-15 for the User Service and authenticated user/project access.
- Add cancellation propagation to workers.
- Add image outputs and final PDF reporting to job artifacts.
- Add CV/video workloads using the same planner/runner/reducer contract.
- Add observability export (Prometheus/OpenTelemetry).
- Add per-user/project authorization and signed artifact URLs.
- Add signed artifact URLs.
- Add shard caching and content-addressed input deduplication.
- Add job priority and fair scheduling.
- Add a CLI for submitting and monitoring remote jobs.
- 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.
---
+172 -2
View File
@@ -1,8 +1,26 @@
# SciMesh
SciMesh is a small local framework for scientific workloads on molecular datasets. It currently provides exact molecular similarity search and exact sparse similarity-graph construction. It runs in one local Python process: there is no network service, multiprocessing, coordinator, database, or dense similarity matrix.
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
@@ -21,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:
@@ -38,6 +116,41 @@ scimesh similarity-search --help
scimesh similarity-graph --help
```
## Manual pipeline demo
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
.venv/bin/pip install -e '.[dev]'
make demo-ui
```
The MkDocs documentation site is served inside the UI at `/ui/docs/`
(`make docs` builds it from `mkdocs/`; the demo mounts `site/`
automatically, or set `SCIMESH_DOCS_DIR` for a manual coordinator). The site
covers the complete Workload SDK: guides (`mkdocs/sdk/`), the full
auto-generated API reference for `scimesh.sdk` (`mkdocs/api/`), and the
documentation rules the site is written by (`mkdocs/approach.md`).
Open `http://localhost:18080/ui` and sign in with username
`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.
## Similarity search
`similarity-search` finds the top-k molecules most similar to a query. The query is supplied either by ChEMBL ID or by SMILES. It uses Morgan fingerprints with `radius=2` and `fpSize=2048`, Tanimoto similarity, streaming TSV reads, and a bounded heap. Invalid SMILES and the query molecule are skipped.
@@ -105,3 +218,60 @@ 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,
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`, `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).
Dynamic workflows, real Worker concurrency, coordinator-backed GPU allocation,
streaming, and gang execution remain fail-closed until their versioned runtime
features are implemented; declaring those profiles does not silently enable
them.
The included `LocalCoreBatchExecutor` is a trusted, single-threaded in-process
conformance harness. It validates scientific parity, sealed outputs, provenance,
and limits, but intentionally refuses profiles that claim network/process
isolation, secrets, accelerators, gangs, checkpoints, or retries; those require
the future enforcing Agent runtime.
## Team
- [Emil](https://github.com/emil28092005) — Project Lead
- [Kristina](https://github.com/kristtma) — Tech Lead
- [Veniamin](https://t.me/Veniamin_Kt) — Scientific Lead
- [Arkhip](https://github.com/hIpa-ussr) — Programmer
- [Reranchik](https://github.com/RERAN4K) — Programmer
+72 -24
View File
@@ -1,7 +1,7 @@
# SciMesh Status
**Updated:** 2026-07-23
**Branch baseline:** `planning` at `13f9a0b`
**Updated:** 2026-08-02
**Branch baseline:** `main`; this revision adds the single-binary platform.
## Current state
@@ -15,39 +15,87 @@ the reference behaviour for future distributed execution:
- Python Worker skeleton: claim, heartbeat, input checksum validation,
artifact upload, completion and failure reporting.
The Go coordinator, PostgreSQL schema, coordinator artifact storage, planner,
reducer, and end-to-end distributed execution are **not implemented yet**.
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 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
worker operations to the authenticated owner. Its documented v1 contract is in
[`docs/user-service-api-contract.md`](docs/user-service-api-contract.md).
Users can create and revoke worker keys for self-service Worker Agent
enrollment. Untrusted workers require quorum agreement from distinct owners on
the complete result-artifact SHA-256 before a task is accepted.
## Milestone tracker
| CTX | Status | Notes |
| --- | --- | --- |
| CTX-00 API and error contract | Ready to implement | `docs/api-contract.md` created; needs owner review/freeze. |
| CTX-01 Go coordinator bootstrap | Not started | Depends on CTX-00. |
| CTX-02 PostgreSQL migrations | Not started | Depends on CTX-00 and CTX-01. |
| CTX-03 Transactional queue | Not started | Depends on CTX-02. |
| CTX-04 Worker registry and HTTP API | Not started | Depends on CTX-03. |
| CTX-05 Artifact storage | Not started | Depends on CTX-02 and CTX-04. |
| CTX-06 Python Worker live-contract alignment | Partially prepared | Worker skeleton exists; needs real Go contract tests. |
| CTX-07 Distributed workload protocol | Not started | Depends on artifact and Worker contracts. |
| CTX-08 Distributed similarity-search | Not started | Local reference exists. |
| CTX-09 Reducer and final-result API | Not started | Depends on CTX-07 and CTX-08. |
| CTX-10 Distributed similarity-graph | Not started | Local reference exists. |
| CTX-11 Dashboard/operator view | Not started | Deferred until API and reducer work. |
| CTX-12 Reliability, security, CI | Not started | Final milestone. |
| 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 | 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`, 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
Assign **CTX-00** to the coordinator role in `.agents/coordinator.md`: review
and freeze `docs/api-contract.md` against `PLAN.md`. Do not begin coordinator
or Worker API implementation until the contract owner accepts it.
Assign **CTX-10** to the distributed-science role: implement deterministic
block-pair planning and reduction for `similarity-graph`.
## Known constraints
- Distributed execution is not available; use the local `scimesh` CLI.
- No Go module, PostgreSQL migrations, runtime configuration, or integration
environment exists yet.
- Local worker unit tests do not prove interoperability with a live coordinator.
- The worker/coordinator flow accepts both underscore API workload names and
hyphenated names at the runner boundary; the runner normalizes them.
- The worker executes SDK-built workloads through `scimesh/worker/runners.py`
(a workload-generic v1-wire bridge over `TaskSpec`/`LocalTaskContext`);
`query_id` resolution and parameter validation live in the workload itself.
`max_rows` is a plan-time option and is rejected per task by the stage
projection.
- A real-stack worker test uses a small `query_smiles` shard. The Python
planner resolves `query_id` once and shares `query_smiles`; the upload UI
currently accepts `query_smiles` only.
- The coordinator accepts uploaded distributed jobs only for
`similarity-search` with `query_smiles`. It rejects `similarity-graph` until
CTX-10 supplies cross-shard pair planning.
- The SDK can execute `core-batch-v1` locally, but the protocol-v1 coordinator
still has flat single-input/single-result tasks and no package/resource
leases. General DAG, concurrent-Agent, GPU, stream, and gang execution needs
a versioned coordinator/Worker rollout; unsupported features fail before
planner invocation.
- The local SDK executor is intentionally trusted and in-process. It does not
enforce process/network/timeout/credential isolation and rejects declarations
that would require those guarantees.
## Update rule
+14
View File
@@ -0,0 +1,14 @@
# Keep the build context small and never bake secrets or local state into an image.
.env
.git
.gitignore
*.md
Makefile
docker-compose.yml
Dockerfile
.dockerignore
# Local build artifacts
/coordinator
/bin/
*.out
+32
View File
@@ -0,0 +1,32 @@
# Copy to .env and adjust. All settings are read from the environment.
COORDINATOR_ADDR=:8080
DATABASE_URL=postgres://scimesh:scimesh@localhost:5432/scimesh?sslmode=disable
# Shared bearer token every worker must present. Leave empty to disable auth (dev only).
WORKER_AUTH_TOKEN=change-me
# Optional local operator UI. Use a separate value; never reuse the worker token.
# When empty, /ui is disabled.
UI_AUTH_TOKEN=
# Logging. LOG_LEVEL: debug|info|warn|error. LOG_FILE empty = stdout only;
# set a path to also write a size-rotated file (kept across restarts).
LOG_LEVEL=info
# LOG_FILE=./logs/coordinator.log
# Directory where artifact bytes are stored.
COORDINATOR_STORAGE_DIR=./data
# Upper bound on an uploaded dataset or artifact body (bytes). Default 1 GiB.
MAX_UPLOAD_BYTES=1073741824
# Optional tuning (defaults shown).
DB_MAX_CONNS=10
# How long to keep retrying the initial DB connection while Postgres boots.
DB_CONNECT_TIMEOUT=30s
REQUEST_TIMEOUT=15s
LEASE_DURATION=2m
DEFAULT_MAX_ATTEMPTS=3
REAPER_INTERVAL=30s
# A worker silent longer than this is marked offline by the reaper.
WORKER_OFFLINE_AFTER=1m
+6
View File
@@ -0,0 +1,6 @@
/coordinator
/bin/
.env
*.out
/logs/
/data/
+54
View File
@@ -0,0 +1,54 @@
version: "2"
run:
timeout: 3m
linters:
# "standard" = errcheck, govet, ineffassign, staticcheck, unused.
default: standard
enable:
# Catches `err == ErrFoo` where errors.Is is required. Directly relevant
# here: domain exposes sentinel errors that use cases may wrap with %w.
- errorlint
# Returning nil after checking a non-nil error — a silent bug factory.
- nilerr
# http.Get/Do without a context: every outbound call must be cancellable.
- noctx
# Unclosed response bodies leak connections.
- bodyclose
# Common security mistakes (weak crypto, unhandled file perms).
- gosec
# Style and naming consistency.
- revive
- misspell
- unconvert
settings:
errcheck:
# Deferred Close/Rollback are intentionally ignored in a few places
# (rollback after commit is a documented no-op).
check-type-assertions: true
revive:
rules:
- name: exported
disabled: true # internal packages need no exported-symbol comments
gosec:
excludes:
- G404 # math/rand is fine for jitter; nothing here is security-sensitive
exclusions:
rules:
# Tests may skip error checks and use long literals freely.
- path: _test\.go
linters:
- errcheck
- gosec
formatters:
enable:
- gofmt
- goimports
settings:
goimports:
local-prefixes:
- github.com/emil28092005/SciMesh/coordinator
+144
View File
@@ -0,0 +1,144 @@
# Архитектура координатора
Карта кода. Читать сверху вниз: сначала «где что лежит», потом «как проходит
запрос», в конце — «куда добавлять новое».
---
## 1. Четыре слоя
```
infra конфиг, пул БД, часы, HTTP-сервер, reaper ← драйверы
transport HTTP-хендлеры ← входящее: кто зовёт нас
storage репозитории на SQL ← исходящее: кого зовём мы
usecase операции + ПОРТЫ (интерфейсы) ← прикладные правила
domain Task, Job и их инварианты ← бизнес-правила
┌── transport ──┐
domain ◄── usecase ◄┤ ├◄── infra
└── storage ────┘
```
`transport` и `storage` — один и тот же слой (в книгах он зовётся «адаптеры»),
просто разделённый по направлению: транспорт принимает запросы снаружи, storage
обращается наружу сам. Так путь к файлу говорит о его роли, а не о категории.
**Единственное правило:** зависимости идут только внутрь. `domain` не импортирует
ничего из проекта. `usecase` видит только `domain`. `transport` и `storage` не
знают друг о друге.
Проверить в любой момент:
```sh
go list -f '{{range .Imports}}{{.}}{{"\n"}}{{end}}' ./internal/domain | grep internal
# пусто = правило соблюдено
```
---
## 2. Где что лежит
| Файл | Что внутри | Строк |
| --- | --- | --- |
| `domain/task.go` | `Task` и **все** переходы состояний: аренда, завершение, провал, истечение | ~245 |
| `domain/job.go` | `Job`, разбиение на чанки, вывод статуса из счётчиков задач | ~107 |
| `domain/errors.go` | Нарушения бизнес-правил (`ErrLeaseConflict`, `ErrStaleAttempt`, …) | ~18 |
| `usecase/ports.go` | **Порты**: `TaskRepository`, `JobRepository`, `TxManager`, `Clock` | ~79 |
| `usecase/task.go` | Операции над задачей: claim, renew, complete, fail, expire | ~200 |
| `usecase/job.go` | Операции над job: create, status, results, stitch | ~180 |
| `usecase/dto.go` | Входные структуры юзкейсов | ~51 |
| `transport/http/server.go` | Роутер и сборка middleware | ~60 |
| `transport/http/handlers.go` | По хендлеру на эндпоинт | ~180 |
| `transport/http/dto.go` | JSON-форматы запросов и ответов | ~118 |
| `transport/http/middleware.go` | request-ID, access-лог, bearer-авторизация | ~103 |
| `transport/http/errors.go` | Маппинг доменных ошибок в HTTP-коды | ~55 |
| `storage/postgres/task_repo.go` | SQL по задачам, включая атомарный claim | ~109 |
| `storage/postgres/job_repo.go` | SQL по job'ам | ~39 |
| `storage/postgres/tx.go` | `TxManager`: транзакция через контекст | ~65 |
| `infra/*.go` | Конфиг, пул, часы, сервер, reaper | ~240 |
| `cmd/coordinator/main.go` | **Composition root** — единственное место со всеми конкретными типами | ~73 |
---
## 3. Трасса запроса: `POST /tasks/claim`
Как воркер получает задачу. Четыре остановки, по одной на слой:
```
① transport/http/handlers.go → handleClaim
разбирает JSON, отдаёт usecase.ClaimTaskInput
② usecase/task.go → ClaimTask.Execute
сначала подчищает протухшие аренды, потом просит одну задачу
через ПОРТ TaskRepository (реализацию не знает)
③ usecase/ports.go → TaskRepository.ClaimNext
контракт: «атомарно выдай одну задачу»
④ storage/postgres/task_repo.go → claimNextSQL
SELECT ... FOR UPDATE SKIP LOCKED + UPDATE одним запросом
```
Обратно поднимается `*domain.Task`, юзкейс сужает его до `domain.ClaimedTask`
(воркеру не отдаём `version`, `max_attempts` и чужие ошибки), хендлер
превращает в JSON. Пустая очередь — это `nil, nil` на шаге ② и `204` на ①.
**Трасса `POST /tasks/{id}/result`** такая же, но с одним отличием: решение
принимает **сущность**, а не юзкейс.
```
handlers.go → CompleteTask.Execute → tx.WithinTx(
GetForUpdate → task.CompleteWith(...) ←── ЗДЕСЬ правила
│ (чужая аренда? устаревший
Update ←─────────────┘ attempt? повтор того же
syncJobStatus манифеста?)
)
```
---
## 4. Куда добавлять новое
| Хочу… | Правлю |
| --- | --- |
| новое бизнес-правило (когда задачу можно повторить) | `domain/task.go` + тест рядом |
| новую операцию (отменить job) | `usecase/job.go` + порт в `ports.go`, если нужен новый запрос к БД |
| новый HTTP-эндпоинт | `transport/http/handlers.go` + маршрут в `server.go` + DTO в `dto.go` |
| новый SQL-запрос | `storage/postgres/*_repo.go` |
| новую настройку | `infra/config.go` + `.env.example` |
| поменять код ответа на ошибку | `transport/http/errors.go` |
**Правило при сомнении:** если код можно описать фразой «когда X, то Y» без
упоминания HTTP, SQL и конфигов — это `domain`. Если он оркеструет несколько
шагов и транзакцию — `usecase`. Если знает про JSON — `transport`, про SQL — `storage`.
---
## 5. Три вещи, которые надо понять один раз
**Порты объявляет потребитель.** `TaskRepository` описан в `usecase/ports.go`, а
реализован в `storage/postgres`. Поэтому `usecase` не импортирует `storage`
стрелка зависимости смотрит внутрь, хотя вызов на рантайме идёт наружу.
**Транзакция едет в контексте.** `TxManager.WithinTx` кладёт `pgx.Tx` в контекст
по неэкспортируемому ключу; репозитории достают её через `conn(ctx, pool)`.
Благодаря этому юзкейс говорит «сделай это атомарно», ни разу не упомянув pgx.
**Атомарный claim нельзя разложить на шаги.** `ClaimNext` — один SQL-запрос,
потому что `SELECT` + отдельный `UPDATE` вернул бы гонку, при которой одну
задачу выдают двум воркерам. Поэтому `ClaimTask.Execute` выглядит тонким: там
нечего оркестровать, вся гарантия — внутри запроса.
---
## 6. Что уже работает, а что заглушка
Работает: слои и проводка, роутинг, авторизация, access-лог, маппинг ошибок,
транзакции, graceful shutdown, миграции, **весь domain с 12 юнит-тестами без БД**.
Заглушки (`ErrNotImplemented` → HTTP 501): методы репозиториев. SQL для двух
главных операций уже написан в `task_repo.go``claimNextSQL` и
`expireLeasesSQL`, осталось их подключить.
+55
View File
@@ -0,0 +1,55 @@
# syntax=docker/dockerfile:1
#
# Requires BuildKit (the RUN --mount cache lines below). Docker 23+ enables it
# by default when the buildx plugin is present; install `docker-buildx` if a
# build fails with "the --mount option requires BuildKit".
# --- build stage ----------------------------------------------------------
FROM golang:1.25-alpine AS build
WORKDIR /src
# Copy manifests first: this layer stays cached until dependencies actually
# change, so editing Go sources does not re-download the module graph.
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod go mod download
COPY . .
# Release builds inject the tag via build-arg; local builds stay "dev".
ARG VERSION=dev
# The cache mounts persist the module cache and the compiler's build cache
# *across* builds, so a rebuild after a code edit recompiles only what changed
# instead of the whole dependency tree.
#
# CGO_ENABLED=0 produces a fully static binary, so the runtime image needs no
# libc. -trimpath strips local paths; -s -w drop the symbol table and DWARF.
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 GOOS=linux go build \
-trimpath -ldflags="-s -w -X main.version=${VERSION#v}" \
-o /out/coordinator ./cmd/coordinator
# --- runtime stage --------------------------------------------------------
FROM alpine:3.20
# ca-certificates for outbound TLS; wget backs the container healthcheck.
RUN apk add --no-cache ca-certificates wget \
&& adduser -D -H -u 10001 coordinator \
# Pre-create the storage and log dirs owned by the non-root user. A named
# volume mounted here inherits this ownership from the image, so the process
# can write to it — a host bind mount, owned by root, cannot.
&& mkdir -p /var/lib/scimesh/artifacts /var/log/scimesh \
&& chown -R coordinator:coordinator /var/lib/scimesh /var/log/scimesh
COPY --from=build /out/coordinator /usr/local/bin/coordinator
# Never run as root: a compromised process should not own the container.
USER coordinator
EXPOSE 8080
# Exec form, not shell: the binary becomes PID 1 and receives SIGTERM directly,
# which is what its graceful shutdown depends on.
ENTRYPOINT ["/usr/local/bin/coordinator"]
+205
View File
@@ -0,0 +1,205 @@
.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 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.
CHECK_PROJECT ?= scimesh-check
CHECK_POSTGRES_PORT ?= 55432
CHECK_COORDINATOR_PORT ?= 18080
CHECK_HOST ?= http://localhost:$(CHECK_COORDINATOR_PORT)
CHECK_TOKEN ?= dev-token
CHECK_DATABASE_URL ?= postgres://scimesh:scimesh@localhost:$(CHECK_POSTGRES_PORT)/scimesh?sslmode=disable
CHECK_COMPOSE = POSTGRES_PORT=$(CHECK_POSTGRES_PORT) COORDINATOR_PORT=$(CHECK_COORDINATOR_PORT) docker compose -p $(CHECK_PROJECT)
# --- local manual demo ---------------------------------------------------
# A separate project and ports mean this demo cannot collide with the normal
# `make up` stack or a developer's local PostgreSQL on 5432.
DEMO_PROJECT ?= scimesh-demo
DEMO_POSTGRES_PORT ?= 55432
DEMO_COORDINATOR_PORT ?= 18080
DEMO_UI_TOKEN ?= demo-ui-secret
DEMO_WORKER_TOKEN ?= demo-worker-token
DEMO_WORKERS ?= 2
# Short public knob for `make demo-ui WORKERS=3`; DEMO_WORKERS remains useful
# for scripts and backwards-compatible documentation.
WORKERS ?= $(DEMO_WORKERS)
DEMO_DIR ?= .demo
# workloads.json is the UI workload catalog, generated from the Python SDK
# workload library. It is checked in so the binary embeds it; regenerate it
# whenever workloads or their manifests change (requires the Python venv).
WORKLOADS_JSON := internal/workloads/workloads.json
# Version injected into the binaries via -ldflags; falls back to "dev".
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
LDFLAGS := -s -w -X main.version=$(VERSION)
# The Go worker agent: a static coordinator client that executes SDK
# workloads in a Python subprocess per claimed task.
agent:
CGO_ENABLED=0 go build -trimpath -ldflags="$(LDFLAGS)" -o bin/worker-agent ./cmd/worker-agent
@printf '%s\n' 'Built bin/worker-agent. Configure via environment:' ' COORDINATOR_URL, WORKER_AUTH_TOKEN, WORK_DIR, CPU_COUNT, MEMORY_MB,' ' POLL_INTERVAL, REQUEST_TIMEOUT, HEARTBEAT_INTERVAL, CAPABILITIES,' ' TASK_RUNNER, MAX_TASKS, EXIT_WHEN_IDLE, WORKER_NAME, WORKER_ID'
# The coordinator server as a static binary, the same way the Docker image
# builds it (CGO_ENABLED=0, trimmed). Requires PostgreSQL at runtime.
coordinator:
CGO_ENABLED=0 go build -trimpath -ldflags="$(LDFLAGS)" -o bin/coordinator ./cmd/coordinator
@printf '%s\n' \
'Built bin/coordinator. Configure via environment:' \
' DATABASE_URL, COORDINATOR_ADDR, COORDINATOR_TOKEN, UI_AUTH_TOKEN,' \
' COORDINATOR_STORAGE_DIR, SCIMESH_DOCS_DIR, JWT_SECRET, USERSERVICE_URL' \
' (embedded schema migrations run on startup; AUTO_MIGRATE=false disables)'
# Interactive wizard: checks the database, creates it when missing (via
# POSTGRES_ADMIN_URL or --admin-db), applies the embedded schema, generates a
# JWT_SECRET, and writes a .env file. Non-interactive: SETUP_ARGS=--yes.
setup: coordinator
./bin/coordinator setup $(SETUP_ARGS)
# 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)
help:
@printf '%s\n' \
'SciMesh coordinator commands:' \
' make up / make down Start or stop the normal coordinator stack.' \
' make demo-ui [WORKERS=3] Start isolated UI demo services and local workers.' \
' make demo-logs Follow coordinator logs for the UI demo.' \
' make demo-down Stop the demo services and workers.' \
' make demo-reset Stop the demo and wipe its data volumes.' \
' make workloads-export Regenerate the embedded UI workload catalog.' \
' make setup Interactive one-shot provisioning wizard.' \
' make test / make vet Run Go verification.' \
'' \
'Demo UI: http://localhost:18080/ui (login page; admin root@scimesh.local / rootpassword).'
demo-ui:
@DEMO_PROJECT="$(DEMO_PROJECT)" \
DEMO_POSTGRES_PORT="$(DEMO_POSTGRES_PORT)" \
DEMO_COORDINATOR_PORT="$(DEMO_COORDINATOR_PORT)" \
DEMO_UI_TOKEN="$(DEMO_UI_TOKEN)" \
DEMO_WORKER_TOKEN="$(DEMO_WORKER_TOKEN)" \
DEMO_WORKERS="$(WORKERS)" \
DEMO_DIR="$(DEMO_DIR)" \
./scripts/demo-ui.sh start
demo-down:
@DEMO_PROJECT="$(DEMO_PROJECT)" \
DEMO_POSTGRES_PORT="$(DEMO_POSTGRES_PORT)" \
DEMO_COORDINATOR_PORT="$(DEMO_COORDINATOR_PORT)" \
DEMO_UI_TOKEN="$(DEMO_UI_TOKEN)" \
DEMO_WORKER_TOKEN="$(DEMO_WORKER_TOKEN)" \
DEMO_DIR="$(DEMO_DIR)" \
./scripts/demo-ui.sh stop
demo-reset:
@DEMO_PROJECT="$(DEMO_PROJECT)" \
DEMO_POSTGRES_PORT="$(DEMO_POSTGRES_PORT)" \
DEMO_COORDINATOR_PORT="$(DEMO_COORDINATOR_PORT)" \
DEMO_UI_TOKEN="$(DEMO_UI_TOKEN)" \
DEMO_WORKER_TOKEN="$(DEMO_WORKER_TOKEN)" \
DEMO_DIR="$(DEMO_DIR)" \
./scripts/demo-ui.sh reset
demo-logs:
@DEMO_PROJECT="$(DEMO_PROJECT)" \
DEMO_POSTGRES_PORT="$(DEMO_POSTGRES_PORT)" \
DEMO_COORDINATOR_PORT="$(DEMO_COORDINATOR_PORT)" \
DEMO_UI_TOKEN="$(DEMO_UI_TOKEN)" \
DEMO_WORKER_TOKEN="$(DEMO_WORKER_TOKEN)" \
DEMO_DIR="$(DEMO_DIR)" \
./scripts/demo-ui.sh logs
# --- build / run ---------------------------------------------------------
build:
go build ./...
run:
go run ./cmd/coordinator
test:
go test ./...
# Needs a running PostgreSQL; the spec forbids mocks for these guarantees.
# make test-integration TEST_DATABASE_URL='postgres://...'
test-integration:
TEST_DATABASE_URL="$(TEST_DATABASE_URL)" go test -tags=integration ./... -v
vet:
go vet ./...
# One command that runs everything: unit tests + vet + lint, then brings up the
# stack and runs the integration suite and the end-to-end smoke test.
# Needs Docker. Hand this to a reviewer.
check: vet lint
go test -race ./...
$(CHECK_COMPOSE) up -d --build
@echo "waiting for the coordinator to be ready..."
@attempt=0; until curl -fsS "$(CHECK_HOST)/health" >/dev/null; do \
attempt=$$((attempt + 1)); \
if [ $$attempt -ge 30 ]; then $(CHECK_COMPOSE) logs coordinator; exit 1; fi; \
sleep 1; \
done
TEST_DATABASE_URL="$(CHECK_DATABASE_URL)" \
go test -tags=integration ./internal/storage/postgres/ -v
HOST="$(CHECK_HOST)" TOKEN="$(CHECK_TOKEN)" ./scripts/smoke.sh
@echo "\nall checks passed ✓"
# Runs golangci-lint without installing it system-wide. Install it for speed:
# pacman -S golangci-lint (Arch)
LINT_VERSION := v2.12.2
lint:
@command -v golangci-lint >/dev/null 2>&1 \
&& golangci-lint run --build-tags=integration ./... \
|| go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@$(LINT_VERSION) run --build-tags=integration ./...
tidy:
go mod tidy
# --- migrations ----------------------------------------------------------
# Requires the golang-migrate CLI:
# go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@latest
# DATABASE_URL must be set, e.g.:
# export DATABASE_URL='postgres://scimesh:scimesh@localhost:5432/scimesh?sslmode=disable'
migrate-up:
migrate -path internal/storage/postgres/migrations -database "$(DATABASE_URL)" up
migrate-down:
migrate -path internal/storage/postgres/migrations -database "$(DATABASE_URL)" down 1
# --- docker --------------------------------------------------------------
# `up` starts Postgres, applies migrations, then launches the coordinator.
up:
docker compose up -d --build
down:
docker compose down
# Also drops the database volume — use when the schema is beyond repair.
down-clean:
docker compose down -v
logs:
docker compose logs -f coordinator
ps:
docker compose ps
rebuild:
docker compose up -d --build --force-recreate coordinator
psql:
docker compose exec postgres psql -U scimesh -d scimesh
# --- api ------------------------------------------------------------------
# Exercises every endpoint against a running coordinator; exits non-zero on the
# first unexpected status. See also api/requests.http for clicking through them
# one at a time in an editor.
smoke:
./scripts/smoke.sh
+230
View File
@@ -0,0 +1,230 @@
# SciMesh Coordinator
Durable task-queue server for SciMesh, in Go on PostgreSQL. It owns all database
access; workers talk to it only over HTTP and never receive DB credentials.
Built as a **modular monolith following Clean Architecture** — one binary, four
layers, dependencies pointing strictly inward. See
`docs/database-integration-task.md` and `docs/worker-daemon-task.md` in the repo
root for the full contract.
## Layers
```
infra config, pgxpool, http.Server, clock ← frameworks & drivers
transport http handlers ← inbound: who calls us
storage sql repositories ← outbound: who we call
usecase business operations + PORTS ← application rules
domain Task, Job + their invariants ← enterprise rules
┌── transport ──┐
domain ◄── usecase ◄┤ ├◄── infra
└── storage ────┘
```
`transport` and `storage` are one layer — the "interface adapters" ring — split
by direction rather than by category, so a file's path tells you its role.
The rule that matters: **source dependencies point only inward**. `domain`
imports nothing from this module; `usecase` sees only `domain`; `transport` and
`storage` know nothing of each other. Verify it at any time with:
```sh
go list -f '{{range .Imports}}{{.}}{{"\n"}}{{end}}' ./internal/domain | grep internal # must be empty
```
## Layout
```
coordinator/
cmd/coordinator/main.go # composition root: the only place with concrete types
internal/
domain/ # entities + rules, no I/O
task.go Task, lease/complete/fail/expire transitions
job.go Job, chunk fan-out, status derivation
errors.go business-rule violations
usecase/ # one type per operation, dependencies injected
ports.go TaskRepository, JobRepository, TxManager, Clock
dto.go use-case boundary inputs
task.go claim, renew, complete, fail, expire
job.go create, status, results, stitch
transport/http/ # routing, DTOs, middleware, error mapping
storage/postgres/ # SQL behind the ports; TxManager via context
infra/ # config.go db.go clock.go server.go
migrations/ # golang-migrate SQL, run as an explicit command
```
A full map — file-by-file table, a request traced through every layer, and a
"where do I add X" guide — lives in [ARCHITECTURE.md](ARCHITECTURE.md).
## Quickstart
### With Docker (nothing to install but Docker)
```sh
make up # Postgres → migrations → coordinator
curl localhost:8080/health
make logs # follow the coordinator
make down # stop (add down-clean to drop the DB volume)
```
To enable the local operator UI, set a separate credential before starting:
```sh
UI_AUTH_TOKEN='local-ui-secret' make up
# Open http://localhost:8080/ui and use any username with this value as password.
```
The UI is disabled by default and never accepts the worker bearer token.
The **control room** shows live workers, recent runs, shard state/attempts,
safe failures, coordinator artifacts, and the final CSV for completed
similarity-search jobs. The job page follows the real stages: TSV accepted →
shards execute → workers return CSVs → `reducing` → final deterministic global
top-k result. It polls only its own coordinator read-model and never controls
or exposes worker processes.
For a hands-on run, open `/ui`, choose **New similarity search**, select a
small ChEMBL-style TSV, then leave one or more `scimesh-worker` processes
running in separate terminals. The detail page updates every two seconds and
stops polling after a completed, failed, or cancelled job. Use **Preview CSV**
to inspect a bounded first page of a partial or completed final result before
downloading it. The UI never exposes source datasets or shard inputs; partial
CSVs remain available only as diagnostics.
### One-command manual demo
From the repository root, create the Python environment once, then start a
self-contained UI demo with two local reference workers:
```sh
python3 -m venv .venv
.venv/bin/pip install -e '.[dev]'
make demo-ui
```
This uses a separate Docker project and ports `18080` (coordinator) and
`55432` (PostgreSQL), so it does not conflict with the normal stack. Open
`http://localhost:18080/ui`, use username `operator` and password
`demo-ui-secret`, upload a small ChEMBL TSV, and observe the workers process
it. Change the worker count with `make demo-ui WORKERS=3`; stop all demo
services and workers with `make demo-down`.
The job page shows a live **Processing speed** graph in completed shards per
minute. It uses the coordinator snapshots observed by the open browser tab, so
it is a transparent local-session measurement rather than a persisted metric.
Use **Preview CSV** before downloading a partial diagnostic or completed final
result. Run `make help` from either the repository root or this directory for
the full list of demo commands.
`up` starts three services in order: Postgres waits until `pg_isready` passes, a
one-shot `migrate` container applies the schema and exits, and only then does the
coordinator start — so it never queries a database that has no tables.
> **Needs BuildKit.** The Dockerfile uses `RUN --mount=type=cache` to reuse the
> Go module and compiler caches between builds. If the build fails with
> *"the --mount option requires BuildKit"*, install the buildx plugin —
> `pacman -S docker-buildx` on Arch, `apt install docker-buildx-plugin` on Debian.
### Locally, against your own Postgres
```sh
cp .env.example .env # then edit DATABASE_URL / WORKER_AUTH_TOKEN
# it is loaded automatically — no export needed
make tidy # fetch deps (needs network once)
make migrate-up # apply schema (needs the migrate CLI)
make run # start the server
```
## Configuration
Settings come from the environment. A `.env` file is loaded at startup via
`godotenv` as a local-dev convenience (override its path with `ENV_FILE`):
- a missing `.env` is not an error — production injects real env vars;
- **real environment variables always win** over the file, so an orchestrator's
values are never shadowed by a stale `.env` baked into an image.
See `.env.example`; only `DATABASE_URL` is required.
## Endpoints
| Method | Path | Purpose |
| ------ | ---------------------------------- | --------------------------------------------- |
| POST | `/workers/register` | Register a worker, get its id |
| POST | `/jobs` | Create job + tasks from chunk URIs |
| POST | `/jobs/upload` | Upload a dataset; coordinator chunks it |
| GET | `/jobs/{job_id}` | Aggregate job progress |
| POST | `/tasks/claim` | Atomically lease one task (`204` if none) |
| GET | `/tasks/{task_id}/input` | Download the task's input shard |
| POST | `/tasks/{task_id}/heartbeat` | Renew the caller's lease (→ `running`) |
| PUT | `/tasks/{task_id}/artifacts/{name}`| Upload a partial-result artifact |
| POST | `/tasks/{task_id}/result` | Complete with an artifact id (idempotent) |
| POST | `/tasks/{task_id}/failure` | Record failure / retryable state |
| GET | `/artifacts/{artifact_id}/download`| Download an artifact by id |
| GET | `/health` | Readiness incl. database (unauthenticated) |
The full contract is in [`docs/api-contract.md`](../docs/api-contract.md) and
[`docs/openapi.yaml`](../docs/openapi.yaml); a worker-author guide is in
[`docs/building-workers.md`](../docs/building-workers.md).
## Poking the API
Two ways, both checked in:
```sh
make smoke # every endpoint, asserted; non-zero exit on failure
```
`api/requests.http` runs the same calls one at a time from an editor with a REST
client (VSCodium/VS Code "REST Client", JetBrains HTTP Client). Later requests
reuse ids captured from earlier responses, so it doubles as API documentation.
## Status
Works end to end: a worker registers, a dataset is uploaded and chunked into
shard tasks (or a job is created from chunk URIs), tasks are leased one at a
time, downloaded, heartbeated (`leased → running`), completed via uploaded
result artifacts, and reflected in job progress. A reaper reclaims expired
leases and marks silent workers offline.
Done: schema + migrations, atomic claim (`FOR UPDATE SKIP LOCKED`), optimistic
concurrency, result/failure paths, lease expiry, worker registry + liveness,
artifact storage, dataset upload + chunking, request-size limits.
Still stubbed: `StitchJob.Execute` — merging per-chunk top-k into the final CSV
is workload semantics that belongs to the Python side (reducer).
## Tests
Unit tests need **no database** — domain rules, use-case orchestration (over
in-memory `internal/memstore`), and HTTP handlers (via `httptest`):
```sh
make test # go test ./...
make vet
make lint
go test -race ./...
```
Integration tests run against a **real PostgreSQL** (the spec forbids mocks
here — they verify `FOR UPDATE SKIP LOCKED`, optimistic concurrency, rollback):
```sh
docker compose up -d
make test-integration TEST_DATABASE_URL='postgres://scimesh:scimesh@localhost:5432/scimesh?sslmode=disable'
```
CI (`.github/workflows/coordinator.yml`) runs vet, gofmt, race tests, lint, and
the integration suite against a Postgres service on every push and PR.
For the complete local verification, including an isolated Docker PostgreSQL
and the HTTP smoke flow, run:
```sh
make check
```
It uses Compose project `scimesh-check` and ports `55432`/`18080` by default,
so it does not connect to a PostgreSQL already running on `5432`. Override
`CHECK_POSTGRES_PORT`, `CHECK_COORDINATOR_PORT`, or `CHECK_PROJECT` if needed.
+234
View File
@@ -0,0 +1,234 @@
# SciMesh Coordinator — API requests
#
# Runnable from any editor with a REST client (VSCodium/VS Code "REST Client",
# JetBrains HTTP Client). Click "Send Request" above each block, top to bottom:
# later requests reuse ids captured from earlier responses.
#
# Start the stack first: docker compose up -d
@host = http://localhost:8080
@token = change-me
@worker = worker-1
### Readiness — the only unauthenticated endpoint (probes the database)
GET {{host}}/health
### Auth check — no token must be rejected with 401
POST {{host}}/tasks/claim
Content-Type: application/json
{ "worker_id": "{{worker}}" }
### 0. Register a worker (201)
# @name register
POST {{host}}/workers/register
Authorization: Bearer {{token}}
Content-Type: application/json
{
"name": "lab-worker-01",
"capabilities": ["similarity_search"],
"cpu_count": 8,
"memory_mb": 16384
}
@workerId = {{register.response.body.worker_id}}
### 0b. Upload a dataset — the coordinator splits it into shard tasks (201)
# Text fields first, the file part last (it is streamed, not buffered).
# @name uploadJob
POST {{host}}/jobs/upload
Authorization: Bearer {{token}}
Content-Type: multipart/form-data; boundary=----scimesh
------scimesh
Content-Disposition: form-data; name="workload"
similarity_search
------scimesh
Content-Disposition: form-data; name="parameters"
{"top_k":10}
------scimesh
Content-Disposition: form-data; name="chunk_rows"
2
------scimesh
Content-Disposition: form-data; name="file"; filename="chembl.tsv"
Content-Type: text/tab-separated-values
id smiles
A CC
B CCC
C CCCC
D CCCCC
------scimesh--
### Download a task's input shard (200) — taskId must be a shard task from an
### uploaded job (claim one first; its input.uri is /tasks/{id}/input).
GET {{host}}/tasks/{{taskId}}/input
Authorization: Bearer {{token}}
### 1. Create a job and its chunks (201)
# The coordinator splits the submission into one task per chunk, transactionally.
# @name createJob
POST {{host}}/jobs
Authorization: Bearer {{token}}
Content-Type: application/json
{
"workload": "similarity_search",
"input_uri": "s3://chembl/full.sdf",
"parameters": { "top_k": 10 },
"chunks": [
{ "chunk_index": 0, "input_uri": "s3://chembl/shard-0.sdf", "input_sha256": "aaa", "max_attempts": 3 },
{ "chunk_index": 1, "input_uri": "s3://chembl/shard-1.sdf", "input_sha256": "bbb", "max_attempts": 3 },
{ "chunk_index": 2, "input_uri": "s3://chembl/shard-2.sdf", "input_sha256": "ccc", "max_attempts": 3 }
]
}
@jobId = {{createJob.response.body.id}}
### 2. Claim a task (200, or 204 when the queue is empty)
# Each call leases a different task; run it repeatedly to see chunk_index advance.
# @name claim
POST {{host}}/tasks/claim
Authorization: Bearer {{token}}
Content-Type: application/json
{
"worker_id": "{{worker}}",
"capabilities": ["similarity_search"],
"max_concurrency": 1
}
@taskId = {{claim.response.body.task_id}}
@attempt = {{claim.response.body.attempt}}
### 3. Heartbeat — renew the lease while the task is still running (200)
POST {{host}}/tasks/{{taskId}}/heartbeat
Authorization: Bearer {{token}}
Content-Type: application/json
{
"worker_id": "{{worker}}",
"attempt": {{attempt}}
}
### 3a. Upload a partial-result artifact (200) — while the task is leased
# Identity travels in headers per the contract; the body is streamed as-is.
# @name uploadArtifact
PUT {{host}}/tasks/{{taskId}}/artifacts/result.csv
Authorization: Bearer {{token}}
Content-Type: text/csv
X-Worker-ID: {{worker}}
X-Task-Attempt: {{attempt}}
query,match,score
CHEMBL25,CHEMBL139,0.87
@artifactId = {{uploadArtifact.response.body.artifact_id}}
### 3b. Download the artifact by id (200)
GET {{host}}/artifacts/{{artifactId}}/download
Authorization: Bearer {{token}}
### 3c. Upload a second artifact — used by the conflict check below (200)
# @name uploadArtifact2
PUT {{host}}/tasks/{{taskId}}/artifacts/secondary.csv
Authorization: Bearer {{token}}
Content-Type: text/csv
X-Worker-ID: {{worker}}
X-Task-Attempt: {{attempt}}
query,match,score
CHEMBL25,CHEMBL521,0.42
@artifactId2 = {{uploadArtifact2.response.body.artifact_id}}
### 4. Submit the result, referencing the uploaded artifact (200)
POST {{host}}/tasks/{{taskId}}/result
Authorization: Bearer {{token}}
Content-Type: application/json
{
"worker_id": "{{worker}}",
"attempt": {{attempt}},
"result": { "artifact_id": "{{artifactId}}", "content_type": "text/csv" },
"metrics": { "elapsed_ms": 1234, "candidates": 50000 }
}
### 4a. Replay the same result — must be idempotent (200, not 409)
POST {{host}}/tasks/{{taskId}}/result
Authorization: Bearer {{token}}
Content-Type: application/json
{
"worker_id": "{{worker}}",
"attempt": {{attempt}},
"result": { "artifact_id": "{{artifactId}}" }
}
### 4b. A different artifact for the same task — conflict (409)
POST {{host}}/tasks/{{taskId}}/result
Authorization: Bearer {{token}}
Content-Type: application/json
{
"worker_id": "{{worker}}",
"attempt": {{attempt}},
"result": { "artifact_id": "{{artifactId2}}" }
}
### 4c. Another worker submitting for this task — conflict (409)
POST {{host}}/tasks/{{taskId}}/result
Authorization: Bearer {{token}}
Content-Type: application/json
{
"worker_id": "impostor",
"attempt": {{attempt}},
"result": { "artifact_id": "{{artifactId}}" }
}
### 5. Report a failure instead (200)
# retryable=true returns the task to the queue while attempts remain;
# retryable=false fails it terminally.
POST {{host}}/tasks/{{taskId}}/failure
Authorization: Bearer {{token}}
Content-Type: application/json
{
"worker_id": "{{worker}}",
"attempt": {{attempt}},
"error_code": "download_failed",
"error_message": "checksum mismatch on shard",
"retryable": true
}
### 6. Job progress (200)
GET {{host}}/jobs/{{jobId}}
Authorization: Bearer {{token}}
### --- error cases -------------------------------------------------------
### Malformed UUID in the path (400)
POST {{host}}/tasks/not-a-uuid/result
Authorization: Bearer {{token}}
Content-Type: application/json
{ "worker_id": "{{worker}}", "attempt": 1, "result_uri": "s3://x", "result_sha256": "x" }
### Unknown field in the body (400) — a misspelled key must not pass silently
POST {{host}}/tasks/claim
Authorization: Bearer {{token}}
Content-Type: application/json
{ "worker_ID": "{{worker}}" }
### Unknown job (404)
GET {{host}}/jobs/00000000-0000-0000-0000-000000000000
Authorization: Bearer {{token}}
### Stitching is not implemented yet (501)
# Any endpoint whose use case is still a stub answers 501.
+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
}
+304
View File
@@ -0,0 +1,304 @@
package main
import (
"context"
"flag"
"fmt"
"log/slog"
"os"
"os/signal"
"sync"
"syscall"
"github.com/emil28092005/SciMesh/coordinator/internal/infra"
"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"
)
// version is injected at build time (-ldflags "-X main.version=...") and
// reported by --version. "dev" marks a local build.
var version = "dev"
func main() {
args := os.Args[1:]
if len(args) > 0 {
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
}
}
showVersion := flag.Bool("version", false, "print the build version and exit")
flag.Parse()
if *showVersion {
fmt.Println("coordinator " + version)
return
}
// All work happens in run() so its defers (pool.Close, log flush, signal
// stop) still execute: os.Exit skips deferred calls entirely.
if err := run(); err != nil {
os.Exit(1)
}
}
// 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 {
boot := slog.New(slog.NewJSONHandler(os.Stderr, nil))
cfg, err := infra.LoadConfig()
if err != nil {
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)
if err != nil {
boot.Error("init logger", "err", err)
return err
}
defer func() { _ = logCloser.Close() }()
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
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("init storage", "err", err)
return err
}
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 := deps.migrate(ctx, log); err != nil {
log.Error("apply migrations", "err", err)
return err
}
}
blobStore, err := blob.NewFSStore(cfg.StorageDir)
if err != nil {
log.Error("init blob storage", "err", err)
return err
}
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 {
log.Error("load workload catalog", "err", err)
return err
}
useCases := httptransport.UseCases{
RegisterWorker: usecase.NewRegisterWorker(workerRepo, clk),
CreateJob: usecase.NewCreateJob(jobRepo, taskRepo, tx, clk),
SubmitDataset: usecase.NewSubmitDataset(blobStore, artifactRepo, jobRepo, taskRepo, tx, clk, cfg.DefaultMaxAttempts, 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),
ReduceJob: usecase.NewReduceJob(jobRepo, taskRepo, artifactRepo, blobStore, tx, clk, catalog),
FailTask: usecase.NewFailTask(taskRepo, jobRepo, workerRepo, tx, clk, catalog),
GetJobStatus: usecase.NewGetJobStatus(jobRepo, taskRepo),
CancelJob: usecase.NewCancelJob(jobRepo, taskRepo, tx, clk),
UploadArtifact: usecase.NewUploadArtifact(taskRepo, workerRepo, artifactRepo, blobStore, tx, clk),
DownloadArtifact: usecase.NewDownloadArtifact(artifactRepo, blobStore),
GetJobResult: usecase.NewGetJobResult(jobRepo, usecase.NewDownloadArtifact(artifactRepo, blobStore)),
GetTaskInput: usecase.NewGetTaskInput(taskRepo, artifactRepo, blobStore),
Dashboard: usecase.NewDashboard(uiReadRepo, 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 close() would pull
// connections out from under them.
expireLeases := usecase.NewExpireLeases(taskRepo, jobRepo, tx, clk, catalog)
markOffline := usecase.NewMarkWorkersOffline(workerRepo, clk, cfg.WorkerOfflineAfter)
var wg sync.WaitGroup
for _, r := range []struct {
name string
fn func(context.Context) (int64, error)
}{
{"reaper requeued expired leases", expireLeases.Execute},
{"reaper marked workers offline", markOffline.Execute},
} {
wg.Add(1)
go func(name string, fn func(context.Context) (int64, error)) {
defer wg.Done()
infra.RunPeriodic(ctx, log, name, cfg.ReaperInterval, fn)
}(r.name, r.fn)
}
// Business metrics: gauges of tasks/jobs/workers by status, sampled from the
// database on every Prometheus scrape.
m := metrics.New()
m.RegisterBusiness(func(ctx context.Context) (metrics.Stats, error) {
tasks, jobs, workers, err := deps.statsRepo.Counts(ctx)
return metrics.Stats{Tasks: tasks, Jobs: jobs, Workers: workers}, err
})
// 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, 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
// LIFO, so the deferred stop() would fire *after* the wait below).
//
// 1. stop() cancel the context, telling the reaper to finish
// 2. wg.Wait() let it return from its current tick
// 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()
// would block forever.
stop()
wg.Wait()
log.Info("shutdown complete")
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
}
+339
View File
@@ -0,0 +1,339 @@
package main
import (
"context"
"github.com/emil28092005/SciMesh/coordinator/internal/agent"
"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 belongs to an unrelated project, so `pip install scimesh` would
// fetch a stranger's package. Default: download the wheel attached to our
// own GitHub release for this binary version; SCIMESH_PIP_PACKAGE
// overrides with a custom wheel, checkout or index.
source := os.Getenv("SCIMESH_PIP_PACKAGE")
if source == "" {
url, _, err := agent.ReleaseWheelURL(version)
if err != nil {
log.Warn("scientific runtime venv created, but scimesh is not installed",
"hint", "set SCIMESH_PIP_PACKAGE to your wheel or index")
return
}
downloaded, err := agent.DownloadWheel(context.Background(), url, venvDir)
if err != nil {
log.Warn("could not download the scimesh wheel for this release",
"err", err, "hint", "set SCIMESH_PIP_PACKAGE to your wheel or index")
return
}
source = downloaded
}
// #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()
}
+74
View File
@@ -0,0 +1,74 @@
package main
import (
"context"
"flag"
"fmt"
"log/slog"
"os"
"time"
"github.com/emil28092005/SciMesh/coordinator/internal/setup"
)
// runSetup implements `coordinator setup` with non-interactive flags and an
// interactive fallback for anything still missing.
func runSetup(args []string) error {
flags := flag.NewFlagSet("setup", flag.ContinueOnError)
flags.Usage = func() {
_, _ = fmt.Fprintf(flags.Output(), "usage: coordinator setup [options]\n")
_, _ = fmt.Fprintf(flags.Output(), "Provisions the coordinator database, schema, and local .env settings.\n\n")
flags.PrintDefaults()
}
var (
databaseURL = flags.String("db", "", "coordinator database URL (default: DATABASE_URL)")
adminURL = flags.String("admin-db", "", "maintenance URL to create a missing database (default: same host, 'postgres' db)")
envFile = flags.String("env-file", "", "settings file to write (default: .env)")
force = flags.Bool("force", false, "overwrite an existing settings file")
yes = flags.Bool("yes", false, "non-interactive: use defaults, fail on anything missing")
)
if err := flags.Parse(args); err != nil {
return err
}
if flags.NArg() > 0 {
return fmt.Errorf("setup takes no positional arguments")
}
databaseURLValue := *databaseURL
if databaseURLValue == "" {
databaseURLValue = os.Getenv("DATABASE_URL")
}
envFileValue := *envFile
if envFileValue == "" {
envFileValue = os.Getenv("ENV_FILE")
}
adminURLValue := *adminURL
if adminURLValue == "" {
adminURLValue = os.Getenv("POSTGRES_ADMIN_URL")
}
options := setup.Options{
DatabaseURL: databaseURLValue,
AdminDatabaseURL: adminURLValue,
EnvFile: envFileValue,
Force: *force,
Yes: *yes,
ConnectTimeout: 10 * time.Second,
Out: os.Stdout,
In: os.Stdin,
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
log := slog.New(slog.NewTextHandler(os.Stderr, nil))
log.Info("setup started", "db", setup.SanitizeDatabaseURL(databaseURLValue), "env_file", envFileValue)
summary, err := setup.Run(ctx, options)
if err != nil {
log.Error("setup failed", "err", err)
return err
}
_, _ = fmt.Fprint(os.Stdout, summary)
log.Info("setup complete")
return nil
}
+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
}
+182
View File
@@ -0,0 +1,182 @@
// Command worker-agent is the Go worker agent: a coordinator client that
// 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
// reported by --version. "dev" marks a local build.
var version = "dev"
func main() {
// The wizard and --check need the injected build version too (they resolve
// the release wheel matching this binary), so it is set before dispatch.
agent.Version = version
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:])
if *showVersion {
fmt.Println("worker-agent " + version)
return
}
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)
}
logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
tokens := agent.NewTokenProvider(
config.WorkerKey,
config.UserserviceURL,
config.Token,
config.RequestTimeout,
)
client := agent.NewClient(config.CoordinatorURL, tokens, config.RequestTimeout)
runner := agent.NewTaskRunner(config.TaskRunner)
daemon := agent.NewDaemon(config, client, runner, logger)
if err := daemon.RunForever(); err != nil {
logger.Error("agent stopped", "error", err)
os.Exit(1)
}
}
// 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
}
}
+30
View File
@@ -0,0 +1,30 @@
# Demo overlay: Prometheus scrapes the coordinator's /metrics, Grafana shows the
# provisioned SciMesh dashboard. Merged by scripts/demo-ui.sh with a third -f.
# Both share the coordinator's compose network, so Prometheus reaches it by name.
services:
prometheus:
image: prom/prometheus:v2.54.1
volumes:
- ./monitoring/prometheus.yml:/etc/prometheus/prometheus.yml:ro
ports:
- "${PROMETHEUS_PORT:-19090}:9090"
restart: unless-stopped
grafana:
image: grafana/grafana:11.2.0
depends_on:
- prometheus
environment:
GF_SECURITY_ADMIN_USER: admin
GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_PASSWORD:-admin}
# Anonymous viewing so the demo dashboard opens without a login.
GF_AUTH_ANONYMOUS_ENABLED: "true"
GF_AUTH_ANONYMOUS_ORG_ROLE: Viewer
GF_USERS_DEFAULT_THEME: dark
volumes:
- ./monitoring/grafana/provisioning:/etc/grafana/provisioning:ro
- ./monitoring/grafana/dashboards:/var/lib/grafana/dashboards:ro
ports:
- "${GRAFANA_PORT:-13000}:3000"
restart: unless-stopped
+71
View File
@@ -0,0 +1,71 @@
# Demo overlay: adds the userservice (its own Postgres + migrations) alongside
# the coordinator and wires the two together with a shared JWT secret, so the
# operator UI authenticates through userservice login/registration.
#
# Used only by scripts/demo-ui.sh, merged onto docker-compose.yml with a second
# -f. Not part of the plain `make up` stack.
services:
postgres-users:
image: postgres:16-alpine
environment:
POSTGRES_USER: ${POSTGRES_USER:-scimesh}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-scimesh}
POSTGRES_DB: scimesh_users
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-scimesh} -d scimesh_users"]
interval: 5s
timeout: 3s
retries: 10
start_period: 5s
migrate-users:
image: migrate/migrate:v4.17.1
depends_on:
postgres-users:
condition: service_healthy
volumes:
- ../users/migrations:/migrations:ro
command:
- -path=/migrations
- -database=postgres://${POSTGRES_USER:-scimesh}:${POSTGRES_PASSWORD:-scimesh}@postgres-users:5432/scimesh_users?sslmode=disable
- up
restart: on-failure
userservice:
build:
context: ../users
depends_on:
postgres-users:
condition: service_healthy
migrate-users:
condition: service_completed_successfully
environment:
USERSERVICE_ADDR: ":8081"
DATABASE_URL: postgres://${POSTGRES_USER:-scimesh}:${POSTGRES_PASSWORD:-scimesh}@postgres-users:5432/scimesh_users?sslmode=disable
JWT_SECRET: ${JWT_SECRET}
# Seeds the first admin the very first time it boots (idempotent after).
BOOTSTRAP_ADMIN_EMAIL: ${BOOTSTRAP_ADMIN_EMAIL:-root@scimesh.local}
BOOTSTRAP_ADMIN_PASSWORD: ${BOOTSTRAP_ADMIN_PASSWORD}
LOG_LEVEL: ${LOG_LEVEL:-info}
ports:
- "${USERSERVICE_PORT:-18081}:8081"
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8081/health"]
interval: 10s
timeout: 3s
retries: 3
start_period: 5s
restart: unless-stopped
# Turn the coordinator UI into session mode: the same shared secret verifies
# userservice tokens locally, and USERSERVICE_URL is where login/register proxy.
coordinator:
environment:
JWT_SECRET: ${JWT_SECRET}
USERSERVICE_URL: http://userservice:8081
# Browser/host-facing URLs for the "add your machine" command. A user's
# worker runs on the host, so it reaches the published ports on localhost,
# not the in-cluster service names.
PUBLIC_COORDINATOR_URL: http://localhost:${COORDINATOR_PORT:-8080}
PUBLIC_USERSERVICE_URL: http://localhost:${USERSERVICE_PORT:-8081}
+71
View File
@@ -0,0 +1,71 @@
name: scimesh
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_USER: ${POSTGRES_USER:-scimesh}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-scimesh}
POSTGRES_DB: ${POSTGRES_DB:-scimesh}
ports:
- "${POSTGRES_PORT:-5432}:5432"
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
# Everything else waits on this, so the check must prove the server
# accepts queries — not merely that the port is open.
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-scimesh} -d ${POSTGRES_DB:-scimesh}"]
interval: 5s
timeout: 3s
retries: 10
start_period: 5s
# The coordinator applies its embedded schema migrations on startup
# (AUTO_MIGRATE, on by default), so no separate migration step is needed.
coordinator:
build:
context: .
depends_on:
postgres:
condition: service_healthy
environment:
COORDINATOR_ADDR: ":8080"
# Host is the service name: compose resolves it on the project network.
DATABASE_URL: postgres://${POSTGRES_USER:-scimesh}:${POSTGRES_PASSWORD:-scimesh}@postgres:5432/${POSTGRES_DB:-scimesh}?sslmode=disable
WORKER_AUTH_TOKEN: ${WORKER_AUTH_TOKEN:-dev-token}
# Empty disables /ui. Set this separately from the worker token.
UI_AUTH_TOKEN: ${UI_AUTH_TOKEN:-}
# Directory of the built MkDocs site served at /ui/docs/ (empty disables
# the docs route; the demo mounts ./site automatically).
SCIMESH_DOCS_DIR: ${SCIMESH_DOCS_DIR:-}
DB_MAX_CONNS: "10"
REQUEST_TIMEOUT: "15s"
LEASE_DURATION: "2m"
REAPER_INTERVAL: "30s"
LOG_LEVEL: ${LOG_LEVEL:-info}
# Logs are teed to stdout (docker logs) and this rotated file on a named
# volume, so they survive a rebuild.
LOG_FILE: /var/log/scimesh/coordinator.log
# Artifact bytes live on a named volume, durable across rebuilds.
COORDINATOR_STORAGE_DIR: /var/lib/scimesh/artifacts
ports:
- "${COORDINATOR_PORT:-8080}:8080"
# Named volumes (not host bind mounts): they inherit the image's directory
# ownership, so the non-root process can write to them. A bind mount would
# be root-owned and unwritable by uid 10001.
volumes:
- coordinator_logs:/var/log/scimesh
- coordinator_data:/var/lib/scimesh/artifacts
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8080/health"]
interval: 10s
timeout: 3s
retries: 3
start_period: 5s
restart: unless-stopped
volumes:
pgdata:
coordinator_logs:
coordinator_data:
+41
View File
@@ -0,0 +1,41 @@
module github.com/emil28092005/SciMesh/coordinator
go 1.25.0
require (
github.com/Masterminds/squirrel v1.5.4
github.com/cenkalti/backoff/v4 v4.3.0
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.6.0
github.com/joho/godotenv v1.5.1
github.com/prometheus/client_golang v1.19.1
gopkg.in/natefinch/lumberjack.v2 v2.2.1
)
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
)
+82
View File
@@ -0,0 +1,82 @@
github.com/Masterminds/squirrel v1.5.4 h1:uUcX/aBc8O7Fg9kaISIUsHXdKuqehiXAMQTYX8afzqM=
github.com/Masterminds/squirrel v1.5.4/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA400rg+riTZj10=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
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=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY=
github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw=
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 h1:SOEGU9fKiNWd/HOJuq6+3iTQz8KNCLtVX6idSoTLdUw=
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=
github.com/prometheus/client_golang v1.19.1/go.mod h1:mP78NwGzrVks5S2H6ab8+ZZGJLZUq1hoULYBAYBw1Ho=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc=
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=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
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=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
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=
+121
View File
@@ -0,0 +1,121 @@
package agent
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"sync"
"time"
)
// TokenProvider supplies the current bearer token. A static token is served
// forever; a worker key is exchanged at the userservice for short-lived JWTs
// and refreshed before they expire (mirroring the former Python worker).
type TokenProvider interface {
Token() (string, error)
Refresh() error
}
// StaticToken serves a fixed token forever; empty means no Authorization.
type StaticToken struct{ token string }
func (s *StaticToken) Token() (string, error) { return s.token, nil }
func (s *StaticToken) Refresh() error { return nil }
// WorkerKeyToken exchanges a long-lived worker key for short-lived JWTs.
type WorkerKeyToken struct {
userserviceURL string
workerKey string
timeout time.Duration
leeway float64
mu sync.Mutex
token string
refreshAt time.Time
}
func NewWorkerKeyToken(userserviceURL, workerKey string, timeout time.Duration) *WorkerKeyToken {
return &WorkerKeyToken{
userserviceURL: strings.TrimRight(userserviceURL, "/"),
workerKey: workerKey,
timeout: timeout,
leeway: 0.2,
}
}
// Token returns the current token, exchanging first when missing or stale.
func (p *WorkerKeyToken) Token() (string, error) {
p.mu.Lock()
defer p.mu.Unlock()
if p.token == "" || time.Now().After(p.refreshAt) {
if err := p.exchangeLocked(); err != nil {
return "", err
}
}
return p.token, nil
}
// Refresh forces an immediate exchange.
func (p *WorkerKeyToken) Refresh() error {
p.mu.Lock()
defer p.mu.Unlock()
return p.exchangeLocked()
}
func (p *WorkerKeyToken) exchangeLocked() error {
payload, err := json.Marshal(map[string]string{"key": p.workerKey})
if err != nil {
return err
}
request, err := http.NewRequestWithContext(context.Background(), http.MethodPost, p.userserviceURL+"/worker-tokens/exchange", bytes.NewReader(payload))
if err != nil {
return err
}
request.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: p.timeout}
response, err := client.Do(request)
if err != nil {
return fmt.Errorf("worker key exchange request failed")
}
defer func() { _ = response.Body.Close() }()
if response.StatusCode != http.StatusOK {
return fmt.Errorf("worker key exchange rejected with status %d", response.StatusCode)
}
raw, err := io.ReadAll(io.LimitReader(response.Body, 1<<20))
if err != nil {
return fmt.Errorf("worker key exchange request failed")
}
var data map[string]any
if err := json.Unmarshal(raw, &data); err != nil {
return fmt.Errorf("worker key exchange response is invalid")
}
token, _ := data["token"].(string)
if token == "" {
return fmt.Errorf("worker key exchange response is missing a token")
}
var ttl time.Duration
switch value := data["expires_in"].(type) {
case float64:
ttl = time.Duration(value * float64(time.Second))
case int:
ttl = time.Duration(value) * time.Second
}
p.token = token
p.refreshAt = time.Time{}
if ttl > 0 {
p.refreshAt = time.Now().Add(time.Duration(float64(ttl) * (1.0 - p.leeway)))
}
return nil
}
// NewTokenProvider picks the strategy: a worker key (with userservice) wins
// over a static bearer token.
func NewTokenProvider(workerKey, userserviceURL, bearerToken string, timeout time.Duration) TokenProvider {
if workerKey != "" && userserviceURL != "" {
return NewWorkerKeyToken(userserviceURL, workerKey, timeout)
}
return &StaticToken{token: bearerToken}
}
+99
View File
@@ -0,0 +1,99 @@
package agent
import (
"encoding/json"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"time"
)
func TestWorkerKeyTokenExchangesAndCaches(t *testing.T) {
var exchanges atomic.Int64
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/worker-tokens/exchange" {
http.NotFound(w, r)
return
}
var payload map[string]string
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil || payload["key"] != "scimesh_wk_live_x" {
http.Error(w, "bad key", http.StatusUnauthorized)
return
}
exchanges.Add(1)
writeJSON(w, http.StatusOK, map[string]any{
"token": "jwt-1",
"expires_in": 100,
})
}))
defer server.Close()
provider := NewWorkerKeyToken(server.URL, "scimesh_wk_live_x", 5*time.Second)
token, err := provider.Token()
if err != nil || token != "jwt-1" {
t.Fatalf("token = %q, err = %v", token, err)
}
// The second call within the TTL reuses the cache.
again, err := provider.Token()
if err != nil || again != "jwt-1" {
t.Fatalf("cached token = %q, err = %v", again, err)
}
if exchanges.Load() != 1 {
t.Errorf("exchanges = %d, want 1", exchanges.Load())
}
// An explicit refresh re-exchanges.
if err := provider.Refresh(); err != nil {
t.Fatalf("Refresh: %v", err)
}
if exchanges.Load() != 2 {
t.Errorf("exchanges after refresh = %d, want 2", exchanges.Load())
}
}
func TestWorkerKeyTokenRejectsBadKey(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
}))
defer server.Close()
provider := NewWorkerKeyToken(server.URL, "bad", 5*time.Second)
if _, err := provider.Token(); err == nil {
t.Error("expected exchange failure for a rejected key")
}
}
func TestNewTokenProviderSelectsStrategy(t *testing.T) {
if _, ok := NewTokenProvider("", "", "static", time.Second).(*StaticToken); !ok {
t.Error("expected a static token provider")
}
if _, ok := NewTokenProvider("key", "http://users", "", time.Second).(*WorkerKeyToken); !ok {
t.Error("expected a worker-key provider")
}
}
func TestClientRefreshesTokenOnceOn401(t *testing.T) {
var attempts atomic.Int64
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
attempts.Add(1)
if attempts.Load() == 1 {
w.WriteHeader(http.StatusUnauthorized)
return
}
writeJSON(w, http.StatusOK, map[string]any{})
}))
defer server.Close()
provider := &StaticToken{token: "t"}
client := NewClient(server.URL, provider, 5*time.Second)
status, _, err := client.requestJSON(http.MethodGet, "/ok", map[string]any{})
if err != nil {
t.Fatalf("request: %v", err)
}
if status != http.StatusOK {
t.Errorf("status = %d", status)
}
if attempts.Load() != 2 {
t.Errorf("attempts = %d, want 2 (401 then retry)", attempts.Load())
}
}
+113
View File
@@ -0,0 +1,113 @@
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 {
// The worker executes workloads by spawning scimesh's task runner, so
// the package is a hard requirement, not an optimisation. The PyPI
// name belongs to a different project, so the wizard installs from
// SCIMESH_PIP_PACKAGE instead of suggesting a bare pip install.
report.Scimesh = CheckItem{Name: "scimesh", OK: false, Detail: "the worker runs workloads through scimesh — install it from your wheel or index (SCIMESH_PIP_PACKAGE)"}
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 }
+374
View File
@@ -0,0 +1,374 @@
package agent
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"time"
)
// CoordinatorError is a non-retriable coordinator response.
type CoordinatorError struct{ msg string }
func (e *CoordinatorError) Error() string { return e.msg }
// TransientError is a timeout, connection error, or 5xx response.
type TransientError struct{ msg string }
func (e *TransientError) Error() string { return e.msg }
// ConflictError means the worker no longer owns the task lease.
type ConflictError struct{ msg string }
func (e *ConflictError) Error() string { return e.msg }
// Client speaks the v1 worker contract over HTTP with a token provider.
//
// API calls never follow redirects (a redirect is a contract violation); the
// artifact download follows redirects but strips the Authorization header on
// cross-origin hops, matching the Python worker's SameOriginAuthRedirectHandler.
// A 401 response refreshes the token exactly once and retries, so a lapsed JWT
// does not fail an in-flight task.
type Client struct {
baseURL string
tokens TokenProvider
timeout time.Duration
apiClient *http.Client
dlClient *http.Client
}
func NewClient(baseURL string, tokens TokenProvider, timeout time.Duration) *Client {
return &Client{
baseURL: strings.TrimRight(baseURL, "/"),
tokens: tokens,
timeout: timeout,
apiClient: &http.Client{
Timeout: timeout,
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
},
dlClient: &http.Client{
Timeout: timeout,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 10 {
return fmt.Errorf("too many redirects")
}
// Go strips Authorization on cross-host redirects by default;
// strip it explicitly on any origin change to be safe.
if len(via) > 0 && origin(req.URL) != origin(via[0].URL) {
req.Header.Del("Authorization")
}
return nil
},
},
}
}
func origin(u *url.URL) string {
return u.Scheme + "://" + u.Host
}
func (c *Client) authHeaders() (map[string]string, error) {
token, err := c.tokens.Token()
if err != nil {
return nil, &CoordinatorError{msg: "token refresh failed: " + err.Error()}
}
if token == "" {
return map[string]string{}, nil
}
return map[string]string{"Authorization": "Bearer " + token}, nil
}
func (c *Client) refreshAndRetry() bool {
return c.tokens.Refresh() == nil
}
// Register advertises the worker and returns its identity and heartbeat policy.
func (c *Client) Register(name string, capabilities []string, cpuCount int, memoryMB int) (*RegisteredWorker, error) {
payload := map[string]any{
"name": name,
"capabilities": capabilities,
"cpu_count": cpuCount,
}
if memoryMB > 0 {
payload["memory_mb"] = memoryMB
}
status, body, err := c.requestJSON("POST", "/workers/register", payload)
if err != nil {
return nil, err
}
if status != http.StatusCreated {
return nil, &CoordinatorError{msg: fmt.Sprintf("worker registration rejected with status %d", status)}
}
return ParseRegistered(body)
}
// Claim leases one compatible task, or returns nil when the queue is empty.
func (c *Client) Claim(workerID string, capabilities []string) (*Task, error) {
status, body, err := c.requestJSON("POST", "/tasks/claim", map[string]any{
"worker_id": workerID,
"capabilities": capabilities,
"max_concurrency": 1,
})
if err != nil {
return nil, err
}
if status == http.StatusNoContent {
return nil, nil
}
if status != http.StatusOK {
return nil, &CoordinatorError{msg: fmt.Sprintf("unexpected claim status %d", status)}
}
return ParseTask(body)
}
// Heartbeat renews the lease and returns the new deadline.
func (c *Client) Heartbeat(task *Task, workerID string) (time.Time, error) {
status, body, err := c.requestJSON("POST", "/tasks/"+task.TaskID+"/heartbeat", map[string]any{
"worker_id": workerID,
"attempt": task.Attempt,
})
if err != nil {
return time.Time{}, err
}
if status != http.StatusOK {
if status == http.StatusConflict {
return time.Time{}, &ConflictError{msg: "heartbeat rejected because the task lease was lost"}
}
return time.Time{}, &CoordinatorError{msg: fmt.Sprintf("heartbeat rejected with status %d", status)}
}
raw, ok := body["lease_expires_at"].(string)
if !ok {
return time.Time{}, &CoordinatorError{msg: "heartbeat response is missing lease_expires_at"}
}
lease, err := time.Parse(time.RFC3339, raw)
if err != nil {
return time.Time{}, &CoordinatorError{msg: "heartbeat returned an invalid lease_expires_at"}
}
task.LeaseExpiresAt = lease
task.leaseExpiresRaw = raw
return lease, nil
}
// Submit completes a task with the uploaded coordinator-owned artifact.
func (c *Client) Submit(task *Task, workerID string, uploaded *Uploaded, metrics map[string]any) error {
status, _, err := c.requestJSON("POST", "/tasks/"+task.TaskID+"/result", map[string]any{
"worker_id": workerID,
"attempt": task.Attempt,
"result": map[string]any{"artifact_id": uploaded.ArtifactID},
"metrics": metrics,
})
if err != nil {
return err
}
if status != http.StatusOK && status != http.StatusCreated && status != http.StatusAccepted {
if status == http.StatusConflict {
return &ConflictError{msg: "result rejected because the task lease was lost"}
}
return &CoordinatorError{msg: fmt.Sprintf("result rejected with status %d", status)}
}
return nil
}
// Fail reports a sanitized failure.
func (c *Client) Fail(task *Task, workerID string, code, message string, retryable bool) error {
status, _, err := c.requestJSON("POST", "/tasks/"+task.TaskID+"/failure", map[string]any{
"worker_id": workerID,
"attempt": task.Attempt,
"error_code": code,
"error_message": message,
"retryable": retryable,
})
if err != nil {
return err
}
if status != http.StatusOK && status != http.StatusCreated && status != http.StatusAccepted {
if status == http.StatusConflict {
return &ConflictError{msg: "failure rejected because the task lease was lost"}
}
return &CoordinatorError{msg: fmt.Sprintf("failure report rejected with status %d", status)}
}
return nil
}
// Download streams the task input to destination and returns its SHA-256.
func (c *Client) Download(uri, destination string) (string, error) {
resolved, err := url.Parse(uri)
if err != nil {
return "", fmt.Errorf("invalid input URI: %w", err)
}
if !resolved.IsAbs() {
base, parseErr := url.Parse(c.baseURL)
if parseErr != nil {
return "", fmt.Errorf("invalid coordinator URL")
}
resolved = base.ResolveReference(resolved)
}
if resolved.Scheme != "http" && resolved.Scheme != "https" {
return "", fmt.Errorf("input URI must be an HTTP(S) URL")
}
request, err := http.NewRequestWithContext(context.Background(), http.MethodGet, resolved.String(), nil)
if err != nil {
return "", err
}
headers, err := c.authHeaders()
if err != nil {
return "", err
}
for name, value := range headers {
request.Header.Set(name, value)
}
response, err := c.dlClient.Do(request)
if err != nil {
return "", &TransientError{msg: "input download failed"}
}
defer func() { _ = response.Body.Close() }()
if response.StatusCode == http.StatusUnauthorized && c.refreshAndRetry() {
return c.Download(uri, destination)
}
if response.StatusCode != http.StatusOK {
return "", &CoordinatorError{msg: fmt.Sprintf("input download rejected with status %d", response.StatusCode)}
}
if err := os.MkdirAll(filepath.Dir(destination), 0o750); err != nil {
return "", err
}
// #nosec G304 -- destination is the worker's own attempt directory file.
target, err := os.OpenFile(destination, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600)
if err != nil {
return "", err
}
digest := sha256.New()
_, copyErr := io.Copy(io.MultiWriter(target, digest), response.Body)
closeErr := target.Close()
if copyErr != nil {
_ = os.Remove(destination)
return "", &TransientError{msg: "input download interrupted"}
}
if closeErr != nil {
return "", closeErr
}
return hex.EncodeToString(digest.Sum(nil)), nil
}
// Upload streams a partial artifact and verifies the returned metadata.
func (c *Client) Upload(task *Task, workerID string, path, contentType string) (*Uploaded, error) {
// #nosec G304 -- the upload path is this worker's own artifact file.
file, err := os.Open(path)
if err != nil {
return nil, err
}
info, err := file.Stat()
if err != nil {
_ = file.Close()
return nil, err
}
digest := sha256.New()
if _, err := io.Copy(digest, file); err != nil {
_ = file.Close()
return nil, err
}
if _, err := file.Seek(0, io.SeekStart); err != nil {
_ = file.Close()
return nil, err
}
localSHA := hex.EncodeToString(digest.Sum(nil))
uploadURL := c.baseURL + "/tasks/" + url.PathEscape(task.TaskID) + "/artifacts/" + url.PathEscape(filepath.Base(path))
request, err := http.NewRequestWithContext(context.Background(), http.MethodPut, uploadURL, file)
if err != nil {
_ = file.Close()
return nil, err
}
request.ContentLength = info.Size()
request.Header.Set("Content-Type", contentType)
request.Header.Set("X-Worker-ID", workerID)
request.Header.Set("X-Task-Attempt", strconv.Itoa(task.Attempt))
headers, err := c.authHeaders()
if err != nil {
_ = file.Close()
return nil, err
}
for name, value := range headers {
request.Header.Set(name, value)
}
response, err := c.apiClient.Do(request)
_ = file.Close()
if err != nil {
return nil, &TransientError{msg: "artifact upload failed"}
}
defer func() { _ = response.Body.Close() }()
raw, err := io.ReadAll(io.LimitReader(response.Body, 1<<20))
if err != nil {
return nil, &TransientError{msg: "artifact upload interrupted"}
}
if response.StatusCode == http.StatusConflict {
return nil, &ConflictError{msg: "artifact upload rejected because the task lease was lost"}
}
if response.StatusCode == http.StatusUnauthorized && c.refreshAndRetry() {
return c.Upload(task, workerID, path, contentType)
}
if response.StatusCode != http.StatusOK {
return nil, &CoordinatorError{msg: fmt.Sprintf("artifact upload rejected with status %d", response.StatusCode)}
}
var payload map[string]any
if err := json.Unmarshal(raw, &payload); err != nil {
return nil, &CoordinatorError{msg: "artifact upload returned invalid metadata"}
}
uploaded, err := ParseUploaded(payload)
if err != nil {
return nil, &CoordinatorError{msg: "artifact upload returned invalid metadata"}
}
if uploaded.SHA256 != localSHA || uploaded.SizeBytes != info.Size() {
return nil, &CoordinatorError{msg: "artifact upload metadata does not match local artifact"}
}
return uploaded, nil
}
func (c *Client) requestJSON(method, path string, payload any) (int, map[string]any, error) {
body, err := json.Marshal(payload)
if err != nil {
return 0, nil, err
}
request, err := http.NewRequestWithContext(context.Background(), method, c.baseURL+path, bytes.NewReader(body))
if err != nil {
return 0, nil, err
}
request.Header.Set("Content-Type", "application/json")
headers, err := c.authHeaders()
if err != nil {
return 0, nil, err
}
for name, value := range headers {
request.Header.Set(name, value)
}
response, err := c.apiClient.Do(request)
if err != nil {
return 0, nil, &TransientError{msg: "coordinator request failed"}
}
defer func() { _ = response.Body.Close() }()
raw, err := io.ReadAll(io.LimitReader(response.Body, 1<<20))
if err != nil {
return 0, nil, &TransientError{msg: "coordinator request interrupted"}
}
if response.StatusCode == http.StatusUnauthorized && c.refreshAndRetry() {
return c.requestJSON(method, path, payload)
}
if response.StatusCode >= 500 {
return response.StatusCode, nil, &TransientError{msg: fmt.Sprintf("coordinator returned %d", response.StatusCode)}
}
var decoded map[string]any
if len(raw) > 0 {
if err := json.Unmarshal(raw, &decoded); err != nil {
return response.StatusCode, nil, &CoordinatorError{msg: "coordinator returned invalid JSON"}
}
}
return response.StatusCode, decoded, nil
}
+225
View File
@@ -0,0 +1,225 @@
package agent
import (
"crypto/sha256"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func newTestClient(t *testing.T, server *httptest.Server) *Client {
t.Helper()
return NewClient(server.URL, &StaticToken{token: "test-token"}, 5*time.Second)
}
func TestClientRegisterClaimHeartbeat(t *testing.T) {
var registered, claimed, heartbeated bool
var server *httptest.Server //nolint:staticcheck // the handler closure references server
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer test-token" {
http.Error(w, "missing token", http.StatusUnauthorized)
return
}
switch {
case r.Method == http.MethodPost && r.URL.Path == "/workers/register":
registered = true
writeJSON(w, http.StatusCreated, map[string]any{
"worker_id": "22222222-2222-4222-8222-222222222222",
"heartbeat_interval_seconds": 15,
})
case r.Method == http.MethodPost && r.URL.Path == "/tasks/claim":
claimed = true
writeJSON(w, http.StatusOK, validTaskPayload())
case r.Method == http.MethodPost && r.URL.Path == "/tasks/11111111-1111-4111-8111-111111111111/heartbeat":
heartbeated = true
writeJSON(w, http.StatusOK, map[string]any{
"lease_expires_at": time.Now().Add(2 * time.Minute).UTC().Format(time.RFC3339),
})
default:
http.NotFound(w, r)
}
}))
defer server.Close()
client := newTestClient(t, server)
registeredWorker, err := client.Register("test-worker", []string{"similarity-search"}, 2, 1024)
if err != nil {
t.Fatalf("Register: %v", err)
}
if registeredWorker.WorkerID != "22222222-2222-4222-8222-222222222222" {
t.Errorf("worker id = %q", registeredWorker.WorkerID)
}
task, err := client.Claim("22222222-2222-4222-8222-222222222222", []string{"similarity-search"})
if err != nil {
t.Fatalf("Claim: %v", err)
}
if task == nil || task.Workload != "similarity-search" {
t.Fatalf("claim = %+v", task)
}
renewed, err := client.Heartbeat(task, "22222222-2222-4222-8222-222222222222")
if err != nil {
t.Fatalf("Heartbeat: %v", err)
}
if renewed.Before(time.Now()) {
t.Error("renewed lease is in the past")
}
if !registered || !claimed || !heartbeated {
t.Error("some endpoints were not hit")
}
}
func TestClientClaimEmptyAndConflict(t *testing.T) {
var server *httptest.Server //nolint:staticcheck // the handler closure references server
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/tasks/claim":
w.WriteHeader(http.StatusNoContent)
case "/tasks/11111111-1111-4111-8111-111111111111/heartbeat":
w.WriteHeader(http.StatusConflict)
default:
http.NotFound(w, r)
}
}))
defer server.Close()
client := newTestClient(t, server)
task, err := client.Claim("worker", []string{"similarity-search"})
if err != nil {
t.Fatalf("Claim: %v", err)
}
if task != nil {
t.Error("expected no task for 204")
}
claimed, err := ParseTask(validTaskPayload())
if err != nil {
t.Fatalf("ParseTask: %v", err)
}
if _, err := client.Heartbeat(claimed, "worker"); err == nil {
t.Error("expected conflict error")
} else if !errors.As(err, &conflictError) {
t.Errorf("error type = %T", err)
}
}
func TestClientUploadSubmitFail(t *testing.T) {
var uploadedPath string
var server *httptest.Server //nolint:staticcheck // the handler closure references server
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodPut && strings.HasPrefix(r.URL.Path, "/tasks/11111111-1111-4111-8111-111111111111/artifacts/"):
if r.Header.Get("X-Worker-ID") != "worker" || r.Header.Get("X-Task-Attempt") != "1" {
t.Errorf("missing identity headers: %+v", r.Header)
}
uploadedPath = r.URL.Path
writeJSON(w, http.StatusOK, map[string]any{
"artifact_id": "33333333-3333-4333-8333-333333333333",
"uri": server.URL + "/artifacts/333/download",
"sha256": sha256Of(t, "partial body"),
"size_bytes": int64(len("partial body")),
})
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/result"):
writeJSON(w, http.StatusAccepted, map[string]any{})
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/failure"):
writeJSON(w, http.StatusAccepted, map[string]any{})
default:
http.NotFound(w, r)
}
}))
defer server.Close()
client := newTestClient(t, server)
task, _ := ParseTask(validTaskPayload())
dir := t.TempDir()
partial := filepath.Join(dir, "result.csv")
if err := os.WriteFile(partial, []byte("partial body"), 0o644); err != nil {
t.Fatal(err)
}
uploaded, err := client.Upload(task, "worker", partial, "text/csv")
if err != nil {
t.Fatalf("Upload: %v", err)
}
if uploaded.SizeBytes != int64(len("partial body")) {
t.Errorf("size = %d", uploaded.SizeBytes)
}
if !strings.Contains(uploadedPath, "result.csv") {
t.Errorf("upload path = %q", uploadedPath)
}
if err := client.Submit(task, "worker", uploaded, map[string]any{"rows": 1}); err != nil {
t.Fatalf("Submit: %v", err)
}
if err := client.Fail(task, "worker", "ValueError", "bad input", false); err != nil {
t.Fatalf("Fail: %v", err)
}
}
func TestClientDownloadVerifiesChecksumAndStripsAuthOnRedirect(t *testing.T) {
var redirectedAuth string
bucket := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
redirectedAuth = r.Header.Get("Authorization")
_, _ = w.Write([]byte("input bytes"))
}))
defer bucket.Close()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/tasks/11111111-1111-4111-8111-111111111111/input" {
http.Redirect(w, r, bucket.URL+"/presigned", http.StatusFound)
return
}
http.NotFound(w, r)
}))
defer server.Close()
client := newTestClient(t, server)
destination := filepath.Join(t.TempDir(), "input")
digest, err := client.Download(server.URL+"/tasks/11111111-1111-4111-8111-111111111111/input", destination)
if err != nil {
t.Fatalf("Download: %v", err)
}
if digest != sha256Of(t, "input bytes") {
t.Errorf("digest = %q", digest)
}
if redirectedAuth != "" {
t.Error("Authorization must be stripped on the redirected download")
}
}
func TestSanitizeErrorMessageRedactsPaths(t *testing.T) {
message := SanitizeErrorMessage(
"failed at /home/alice/work/attempts/1/input and /private/secret.txt",
"/home/alice/work",
)
for _, forbidden := range []string{"/home/alice", "/private/secret.txt"} {
if strings.Contains(message, forbidden) {
t.Errorf("message leaks %q: %q", forbidden, message)
}
}
if !strings.Contains(message, "<worker-dir>") {
t.Errorf("work dir not redacted: %q", message)
}
long := SanitizeErrorMessage(strings.Repeat("x", 500), "/tmp")
if len(long) != 300 {
t.Errorf("truncated length = %d", len(long))
}
}
func writeJSON(w http.ResponseWriter, status int, payload any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(payload)
}
func sha256Of(t *testing.T, value string) string {
t.Helper()
digest := sha256.Sum256([]byte(value))
return fmt.Sprintf("%x", digest)
}
+177
View File
@@ -0,0 +1,177 @@
package agent
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/emil28092005/SciMesh/coordinator/internal/workloads"
)
// Config is read only from the environment, mirroring the former Python
// worker's configuration surface.
type Config struct {
CoordinatorURL string
Token string
WorkerKey string
UserserviceURL string
WorkerName string
WorkerID string // set after registration; overridable for tests
WorkDir string
CPUCount int
MemoryMB int // 0 = not advertised
PollInterval time.Duration
RequestTimeout time.Duration
Heartbeat time.Duration
CleanupAfter time.Duration // 0 = keep attempt directories
Capabilities []string
TaskRunner []string // command + args; defaults to python -m scimesh.worker.task
MaxTasks int // 0 = unlimited
ExitWhenIdle bool
}
func envList(name string) ([]string, error) {
raw := os.Getenv(name)
if raw == "" {
return nil, nil
}
var items []string
if err := json.Unmarshal([]byte(raw), &items); err != nil {
return nil, fmt.Errorf("%s must be a JSON array", name)
}
for _, item := range items {
if strings.TrimSpace(item) == "" {
return nil, fmt.Errorf("%s must not contain empty entries", name)
}
}
return items, nil
}
// LoadConfig validates the environment and fails fast on invalid values.
func LoadConfig() (*Config, error) {
url := os.Getenv("COORDINATOR_URL")
if url == "" {
return nil, fmt.Errorf("COORDINATOR_URL is required")
}
if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") {
return nil, fmt.Errorf("COORDINATOR_URL must be an absolute HTTP(S) URL")
}
workDir := os.Getenv("WORK_DIR")
if workDir == "" {
workDir = "./scimesh-agent-data"
}
cpu := 1
if raw := os.Getenv("CPU_COUNT"); raw != "" {
parsed, err := strconv.Atoi(raw)
if err != nil || parsed < 1 {
return nil, fmt.Errorf("CPU_COUNT must be a positive integer")
}
cpu = parsed
}
memoryMB := 0
if raw := os.Getenv("MEMORY_MB"); raw != "" {
parsed, err := strconv.Atoi(raw)
if err != nil || parsed < 1 {
return nil, fmt.Errorf("MEMORY_MB must be a positive integer")
}
memoryMB = parsed
}
poll, err := durationEnv("POLL_INTERVAL", 2*time.Second)
if err != nil {
return nil, err
}
timeout, err := durationEnv("REQUEST_TIMEOUT", 30*time.Second)
if err != nil {
return nil, err
}
heartbeat, err := durationEnv("HEARTBEAT_INTERVAL", 15*time.Second)
if err != nil {
return nil, err
}
cleanup, err := durationEnv("CLEANUP_AFTER_SECONDS", 0)
if err != nil {
return nil, err
}
capabilities, err := envList("CAPABILITIES")
if err != nil {
return nil, err
}
if len(capabilities) == 0 {
capabilities = DefaultCapabilities()
}
runner, err := envList("TASK_RUNNER")
if err != nil {
return nil, err
}
if len(runner) == 0 {
runner = []string{"python", "-m", "scimesh.worker.task"}
}
maxTasks := 0
if raw := os.Getenv("MAX_TASKS"); raw != "" {
parsed, err := strconv.Atoi(raw)
if err != nil || parsed < 1 {
return nil, fmt.Errorf("MAX_TASKS must be a positive integer")
}
maxTasks = parsed
}
name := os.Getenv("WORKER_NAME")
if name == "" {
host, _ := os.Hostname()
name = host
}
absWorkDir, err := filepath.Abs(workDir)
if err != nil {
return nil, fmt.Errorf("WORK_DIR must be an absolute path")
}
return &Config{
CoordinatorURL: strings.TrimRight(url, "/"),
Token: os.Getenv("WORKER_AUTH_TOKEN"),
WorkerKey: os.Getenv("WORKER_KEY"),
UserserviceURL: strings.TrimRight(os.Getenv("USERSERVICE_URL"), "/"),
WorkerName: name,
WorkerID: os.Getenv("WORKER_ID"),
WorkDir: absWorkDir,
CPUCount: cpu,
MemoryMB: memoryMB,
PollInterval: poll,
RequestTimeout: timeout,
Heartbeat: heartbeat,
CleanupAfter: cleanup,
Capabilities: capabilities,
TaskRunner: runner,
MaxTasks: maxTasks,
ExitWhenIdle: os.Getenv("EXIT_WHEN_IDLE") == "1",
}, nil
}
func durationEnv(name string, fallback time.Duration) (time.Duration, error) {
raw := os.Getenv(name)
if raw == "" {
return fallback, nil
}
parsed, err := time.ParseDuration(raw)
if err != nil || parsed < 0 {
return 0, fmt.Errorf("%s must be a non-negative duration", name)
}
return parsed, nil
}
// DefaultCapabilities derives the worker's advertised capabilities from the
// embedded workload catalog, so an agent is workload-agnostic out of the box:
// it claims whatever enabled workloads the coordinator library declares.
// Explicit CAPABILITIES still overrides this for operators who want a subset.
func DefaultCapabilities() []string {
catalog, err := workloads.Load()
if err != nil {
return []string{"similarity-search"}
}
names := make([]string, 0, len(catalog.Enabled()))
for _, workload := range catalog.Enabled() {
names = append(names, workload.Name)
}
return names
}
+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
}
+336
View File
@@ -0,0 +1,336 @@
package agent
import (
"errors"
"fmt"
"log/slog"
"os"
"path/filepath"
"strings"
"sync"
"time"
)
// Outcome reports whether a claim was made and whether it completed.
type Outcome struct {
Claimed bool
Completed bool
}
// Daemon is the agent state machine: register, claim, execute via the Python
// task runner, upload, and submit — mirroring the Python worker's lifecycle.
// conflictError is the errors.As target for lease conflicts.
var conflictError *ConflictError
type Daemon struct {
config *Config
client *Client
runner *TaskRunner
log *slog.Logger
workerID string
registered bool
completed int
mu sync.Mutex
}
func NewDaemon(config *Config, client *Client, runner *TaskRunner, log *slog.Logger) *Daemon {
return &Daemon{config: config, client: client, runner: runner, log: log}
}
// RunForever loops until interrupted, idle-exit, or max tasks.
func (d *Daemon) RunForever() error {
failures := 0
for {
if !d.registered {
if err := d.register(); err != nil {
return err
}
}
d.cleanupExpiredDirectories()
outcome, err := d.runOnce()
if err != nil {
failures++
d.log.Warn("agent cycle failed", "error", err)
backoff := d.config.PollInterval
for i := 0; i < failures && i < 6; i++ {
backoff *= 2
}
if backoff > 60*time.Second {
backoff = 60 * time.Second
}
time.Sleep(backoff)
continue
}
failures = 0
if outcome.Claimed && outcome.Completed {
d.completed++
if d.config.MaxTasks > 0 && d.completed >= d.config.MaxTasks {
d.log.Info("max tasks reached")
return nil
}
}
if !outcome.Claimed && d.config.ExitWhenIdle {
d.log.Info("queue empty, exiting")
return nil
}
if outcome.Claimed && d.config.ExitWhenIdle {
d.log.Info("one claim processed, exiting")
return nil
}
if !outcome.Claimed {
time.Sleep(d.config.PollInterval)
}
}
}
func (d *Daemon) register() error {
registered, err := d.client.Register(
d.config.WorkerName,
d.config.Capabilities,
d.config.CPUCount,
d.config.MemoryMB,
)
if err != nil {
return err
}
d.mu.Lock()
if d.config.WorkerID != "" {
d.workerID = d.config.WorkerID
} else {
d.workerID = registered.WorkerID
}
d.registered = true
d.mu.Unlock()
d.log.Info("registered", "worker_id", d.workerID)
return nil
}
func (d *Daemon) workerIDOrEmpty() string {
d.mu.Lock()
defer d.mu.Unlock()
return d.workerID
}
// cleanupExpiredDirectories removes task attempt directories older than the
// configured retention, mirroring the former Python worker's cleanup.
func (d *Daemon) cleanupExpiredDirectories() {
if d.config.CleanupAfter <= 0 {
return
}
cutoff := time.Now().Add(-d.config.CleanupAfter)
tasks, err := os.ReadDir(d.config.WorkDir)
if err != nil {
return
}
for _, taskEntry := range tasks {
if !taskEntry.IsDir() {
continue
}
taskDir := filepath.Join(d.config.WorkDir, taskEntry.Name())
attempts, err := os.ReadDir(taskDir)
if err != nil {
continue
}
for _, attemptEntry := range attempts {
if !attemptEntry.IsDir() {
continue
}
info, err := attemptEntry.Info()
if err == nil && info.ModTime().Before(cutoff) {
_ = os.RemoveAll(filepath.Join(taskDir, attemptEntry.Name()))
}
}
if entries, err := os.ReadDir(taskDir); err == nil && len(entries) == 0 {
_ = os.Remove(taskDir)
}
}
}
func (d *Daemon) runOnce() (Outcome, error) {
workerID := d.workerIDOrEmpty()
if workerID == "" {
return Outcome{}, fmt.Errorf("agent is not registered")
}
task, err := d.client.Claim(workerID, d.config.Capabilities)
if err != nil {
return Outcome{}, err
}
if task == nil {
return Outcome{Claimed: false}, nil
}
started := time.Now()
taskDir := filepath.Join(d.config.WorkDir, task.TaskID, fmt.Sprint(task.Attempt))
if err := os.MkdirAll(taskDir, 0o750); err != nil {
return Outcome{Claimed: true}, err
}
heartbeat := newLeaseHeartbeat(task, workerID, d.client, d.config.Heartbeat)
completed := false
err = heartbeat.Start()
if err != nil {
if errors.As(err, &conflictError) {
d.log.Warn("lease lost", "task_id", task.TaskID)
return Outcome{Claimed: true}, nil
}
return Outcome{Claimed: true}, err
}
defer heartbeat.Stop()
// Attempt directory cleanup is deliberately minimal in the prototype:
// attempt directories are retained under the work directory.
failure := d.executeTask(task, workerID, taskDir, started, heartbeat)
if failure != nil {
if errors.As(failure, &conflictError) {
d.log.Warn("lease lost", "task_id", task.TaskID)
return Outcome{Claimed: true}, nil
}
if err := heartbeat.RaiseIfFailed(); err != nil {
return Outcome{Claimed: true}, nil
}
d.reportFailure(task, workerID, failure)
return Outcome{Claimed: true}, nil
}
if err := heartbeat.RaiseIfFailed(); err != nil {
return Outcome{Claimed: true}, nil
}
completed = true
d.log.Info("task completed", "task_id", task.TaskID, "elapsed_seconds", time.Since(started).Seconds())
return Outcome{Claimed: true, Completed: completed}, nil
}
// executeTask returns nil on success or a classified failure.
func (d *Daemon) executeTask(task *Task, workerID, taskDir string, started time.Time, heartbeat *leaseHeartbeat) error {
inputPath := filepath.Join(taskDir, "input")
// Downloads use the coordinator-provided URI verbatim; a relative path is
// resolved against the coordinator by the client.
actualSHA, err := d.client.Download(task.Input.URI, inputPath)
if err != nil {
return err
}
if !strings.EqualFold(actualSHA, task.Input.SHA256) {
return &CoordinatorError{msg: "input checksum mismatch"}
}
if err := heartbeat.RaiseIfFailed(); err != nil {
return err
}
manifestPath := filepath.Join(taskDir, "manifest.json")
manifest, err := d.runner.Run(task, taskDir, manifestPath, nil)
if err != nil {
return err
}
if err := heartbeat.RaiseIfFailed(); err != nil {
return err
}
uploaded, err := d.client.Upload(task, workerID, manifest.ArtifactPath, manifest.ContentType)
if err != nil {
return err
}
if err := heartbeat.RaiseIfFailed(); err != nil {
return err
}
metrics := map[string]any{"elapsed_seconds": roundSeconds(time.Since(started).Seconds())}
for name, value := range manifest.Metrics {
metrics[name] = value
}
return d.client.Submit(task, workerID, uploaded, metrics)
}
func (d *Daemon) reportFailure(task *Task, workerID string, failure error) {
var code string
var coordinatorErr *CoordinatorError
if errors.As(failure, &coordinatorErr) {
code = "ValueError"
} else {
code = "TaskRunnerFailed"
}
retryable := IsRetryableError(failure)
message := SanitizeErrorMessage(failure.Error(), d.config.WorkDir)
d.log.Warn("task failed", "task_id", task.TaskID, "error_code", code, "retryable", retryable)
if err := d.client.Fail(task, workerID, code, message, retryable); err != nil {
if errors.As(err, &conflictError) {
d.log.Warn("lease lost while reporting failure", "task_id", task.TaskID)
return
}
d.log.Warn("failure report rejected", "task_id", task.TaskID, "error", err)
}
}
// leaseHeartbeat renews the lease from the returned deadline at less than
// half of the remaining TTL, mirroring the Python worker.
type leaseHeartbeat struct {
task *Task
workerID string
client *Client
interval time.Duration
stop chan struct{}
once sync.Once
mu sync.Mutex
lease time.Time
failed error
}
func newLeaseHeartbeat(task *Task, workerID string, client *Client, interval time.Duration) *leaseHeartbeat {
return &leaseHeartbeat{
task: task,
workerID: workerID,
client: client,
interval: interval,
stop: make(chan struct{}),
lease: task.LeaseExpiresAt,
}
}
func (h *leaseHeartbeat) Start() error {
if _, err := h.client.Heartbeat(h.task, h.workerID); err != nil {
return err
}
go h.loop()
return nil
}
func (h *leaseHeartbeat) Stop() {
h.once.Do(func() { close(h.stop) })
}
func (h *leaseHeartbeat) RaiseIfFailed() error {
h.mu.Lock()
defer h.mu.Unlock()
return h.failed
}
func (h *leaseHeartbeat) loop() {
for {
delay := h.nextDelay()
select {
case <-h.stop:
return
case <-time.After(delay):
}
renewed, err := h.client.Heartbeat(h.task, h.workerID)
h.mu.Lock()
if err != nil {
h.failed = err
h.mu.Unlock()
return
}
h.lease = renewed
h.mu.Unlock()
}
}
func (h *leaseHeartbeat) nextDelay() time.Duration {
h.mu.Lock()
defer h.mu.Unlock()
remaining := time.Until(h.lease)
if remaining <= 0 {
return 0
}
half := remaining / 2
if h.interval < half {
return h.interval
}
return half
}
func roundSeconds(seconds float64) float64 {
return float64(int64(seconds*1000)) / 1000
}
+296
View File
@@ -0,0 +1,296 @@
package agent
import (
"crypto/sha256"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func sha256HexOf(value string) string {
sum := sha256.Sum256([]byte(value))
return fmt.Sprintf("%x", sum)
}
// fakeRunnerScript writes a result manifest for --output and exits with the
// given code.
func fakeRunnerScript(t *testing.T, dir string, exitCode int) string {
t.Helper()
script := filepath.Join(dir, "fake-runner.sh")
content := `#!/bin/sh
out=""
task_dir=""
while [ "$#" -gt 0 ]; do
case "$1" in
--output) out="$2"; shift 2;;
--task-dir) task_dir="$2"; shift 2;;
*) shift;;
esac
done
printf 'id,score\n' > "$task_dir/result.csv"
printf '{"artifact_path":"%s/result.csv","content_type":"text/csv","metrics":{"rows":1}}' "$task_dir" > "$out"
exit ` + fmt.Sprint(exitCode) + "\n"
if err := os.WriteFile(script, []byte(content), 0o755); err != nil {
t.Fatal(err)
}
return script
}
// fakeCoordinator implements the v1 contract over HTTP and records calls.
type fakeCoordinator struct {
server *httptest.Server
task map[string]any
submits []map[string]any
failures []map[string]any
heartbeats int
uploadSHA string
uploadSize int64
inputBytes []byte
conflict bool // 409 on heartbeat/upload/result
}
func newFakeCoordinator(t *testing.T, task map[string]any) *fakeCoordinator {
t.Helper()
fake := &fakeCoordinator{task: task, inputBytes: []byte("input fixture")}
fake.uploadSHA = sha256HexOf(string(fake.inputBytes))
fake.uploadSize = int64(len(fake.inputBytes))
var server *httptest.Server
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodPost && r.URL.Path == "/workers/register":
writeJSON(w, http.StatusCreated, map[string]any{
"worker_id": "22222222-2222-4222-8222-222222222222",
"heartbeat_interval_seconds": 15.0,
})
case r.Method == http.MethodPost && r.URL.Path == "/tasks/claim":
if fake.task == nil {
w.WriteHeader(http.StatusNoContent)
return
}
writeJSON(w, http.StatusOK, fake.task)
case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/input"):
_, _ = w.Write(fake.inputBytes)
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/heartbeat"):
fake.heartbeats++
if fake.conflict {
w.WriteHeader(http.StatusConflict)
return
}
writeJSON(w, http.StatusOK, map[string]any{
"lease_expires_at": time.Now().Add(2 * time.Minute).UTC().Format(time.RFC3339),
})
case r.Method == http.MethodPut && strings.Contains(r.URL.Path, "/artifacts/"):
raw, _ := io.ReadAll(r.Body)
fake.uploadSize = int64(len(raw))
fake.uploadSHA = fmt.Sprintf("%x", sha256.Sum256(raw))
if fake.conflict {
w.WriteHeader(http.StatusConflict)
return
}
writeJSON(w, http.StatusOK, map[string]any{
"artifact_id": "33333333-3333-4333-8333-333333333333",
"uri": server.URL + "/artifacts/333/download",
"sha256": fake.uploadSHA,
"size_bytes": fake.uploadSize,
})
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/result"):
var payload map[string]any
_ = json.NewDecoder(r.Body).Decode(&payload)
fake.submits = append(fake.submits, payload)
if fake.conflict {
w.WriteHeader(http.StatusConflict)
return
}
w.WriteHeader(http.StatusAccepted)
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/failure"):
var payload map[string]any
_ = json.NewDecoder(r.Body).Decode(&payload)
fake.failures = append(fake.failures, payload)
w.WriteHeader(http.StatusAccepted)
default:
http.NotFound(w, r)
}
}))
fake.server = server
return fake
}
func (f *fakeCoordinator) close() { f.server.Close() }
func testDaemon(t *testing.T, fake *fakeCoordinator, script string) *Daemon {
t.Helper()
config := &Config{
CoordinatorURL: fake.server.URL,
WorkerName: "test-worker",
WorkerID: "22222222-2222-4222-8222-222222222222",
WorkDir: t.TempDir(),
CPUCount: 1,
PollInterval: time.Millisecond,
RequestTimeout: 5 * time.Second,
Heartbeat: 15 * time.Second,
Capabilities: []string{"similarity-search"},
TaskRunner: []string{script},
}
client := NewClient(fake.server.URL, &StaticToken{token: "test-token"}, 5*time.Second)
runner := NewTaskRunner(config.TaskRunner)
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
daemon := NewDaemon(config, client, runner, logger)
if err := daemon.register(); err != nil {
t.Fatalf("register: %v", err)
}
return daemon
}
func validClaimedTaskPayload() map[string]any {
return map[string]any{
"task_id": "11111111-1111-4111-8111-111111111111",
"attempt": 1.0,
"lease_expires_at": time.Now().Add(time.Minute).UTC().Format(time.RFC3339),
"workload": "similarity-search",
"input": map[string]any{
"uri": "/tasks/11111111-1111-4111-8111-111111111111/input",
"sha256": sha256HexOf("input fixture"),
},
"parameters": map[string]any{"query_smiles": "CCO"},
}
}
func TestDaemonCompletesAClaimedTask(t *testing.T) {
fake := newFakeCoordinator(t, validClaimedTaskPayload())
defer fake.close()
daemon := testDaemon(t, fake, fakeRunnerScript(t, t.TempDir(), 0))
outcome, err := daemon.runOnce()
if err != nil {
t.Fatalf("runOnce: %v", err)
}
if !outcome.Claimed || !outcome.Completed {
t.Fatalf("outcome = %+v", outcome)
}
if len(fake.submits) != 1 {
t.Fatalf("submits = %d", len(fake.submits))
}
result := fake.submits[0]["result"].(map[string]any)
if result["artifact_id"] != "33333333-3333-4333-8333-333333333333" {
t.Errorf("result artifact = %v", result)
}
metrics := fake.submits[0]["metrics"].(map[string]any)
if metrics["rows"] != float64(1) {
t.Errorf("metrics = %v", metrics)
}
if _, ok := metrics["elapsed_seconds"].(float64); !ok {
t.Errorf("missing elapsed_seconds: %v", metrics)
}
if fake.heartbeats < 1 {
t.Error("expected at least one heartbeat")
}
if len(fake.failures) != 0 {
t.Errorf("unexpected failures: %v", fake.failures)
}
}
func TestDaemonReportsChecksumMismatchAsPermanentFailure(t *testing.T) {
payload := validClaimedTaskPayload()
payload["input"].(map[string]any)["sha256"] = strings.Repeat("b", 64)
fake := newFakeCoordinator(t, payload)
defer fake.close()
daemon := testDaemon(t, fake, fakeRunnerScript(t, t.TempDir(), 0))
outcome, err := daemon.runOnce()
if err != nil {
t.Fatalf("runOnce: %v", err)
}
if outcome.Completed {
t.Fatal("task must not complete on checksum mismatch")
}
if len(fake.failures) != 1 {
t.Fatalf("failures = %d", len(fake.failures))
}
failure := fake.failures[0]
if failure["error_code"] != "ValueError" || failure["retryable"] != false {
t.Errorf("failure = %v", failure)
}
if !strings.Contains(failure["error_message"].(string), "checksum") {
t.Errorf("message = %v", failure["error_message"])
}
if len(fake.submits) != 0 {
t.Error("no submission expected")
}
}
func TestDaemonReportsPermanentRunnerFailure(t *testing.T) {
fake := newFakeCoordinator(t, validClaimedTaskPayload())
defer fake.close()
daemon := testDaemon(t, fake, fakeRunnerScript(t, t.TempDir(), ExitPermanent))
outcome, err := daemon.runOnce()
if err != nil {
t.Fatalf("runOnce: %v", err)
}
if outcome.Completed {
t.Fatal("task must not complete")
}
if len(fake.failures) != 1 || fake.failures[0]["retryable"] != false {
t.Fatalf("failures = %v", fake.failures)
}
}
func TestDaemonReportsRetryableRunnerFailure(t *testing.T) {
fake := newFakeCoordinator(t, validClaimedTaskPayload())
defer fake.close()
daemon := testDaemon(t, fake, fakeRunnerScript(t, t.TempDir(), 1))
outcome, err := daemon.runOnce()
if err != nil {
t.Fatalf("runOnce: %v", err)
}
if outcome.Completed {
t.Fatal("task must not complete")
}
if len(fake.failures) != 1 || fake.failures[0]["retryable"] != true {
t.Fatalf("failures = %v", fake.failures)
}
}
func TestDaemonLeaseConflictStopsWithoutFailureReport(t *testing.T) {
fake := newFakeCoordinator(t, validClaimedTaskPayload())
fake.conflict = true
defer fake.close()
daemon := testDaemon(t, fake, fakeRunnerScript(t, t.TempDir(), 0))
outcome, err := daemon.runOnce()
if err != nil {
t.Fatalf("runOnce: %v", err)
}
if !outcome.Claimed {
t.Fatal("task was claimed")
}
if len(fake.failures) != 0 {
t.Errorf("no failure report expected after lease loss: %v", fake.failures)
}
if len(fake.submits) != 0 {
t.Errorf("no submission expected after lease loss: %v", fake.submits)
}
}
func TestDaemonIdleClaimIsNotCompleted(t *testing.T) {
fake := newFakeCoordinator(t, nil)
defer fake.close()
daemon := testDaemon(t, fake, fakeRunnerScript(t, t.TempDir(), 0))
outcome, err := daemon.runOnce()
if err != nil {
t.Fatalf("runOnce: %v", err)
}
if outcome.Claimed || outcome.Completed {
t.Fatalf("outcome = %+v", outcome)
}
}
+205
View File
@@ -0,0 +1,205 @@
// Package agent implements a Go worker agent: a coordinator client and
// task-lifecycle supervisor that executes SDK workloads in a Python
// subprocess. It mirrors the Python worker's v1 wire contract exactly; the
// Python worker remains the reference implementation.
package agent
import (
"encoding/json"
"fmt"
"net/url"
"regexp"
"strings"
"time"
)
var (
uuidPattern = regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`)
sha256Pattern = regexp.MustCompile(`^[0-9a-fA-F]{64}$`)
workloadPattern = regexp.MustCompile(`^[a-z][a-z0-9]*(?:[_-][a-z0-9]+)*$`)
)
// RegisteredWorker is the coordinator's answer to /workers/register.
type RegisteredWorker struct {
WorkerID string
HeartbeatIntervalSeconds float64
}
// Input is the claimed task's input artifact.
type Input struct {
URI string
SHA256 string
}
// Task is one claimed, leased task.
type Task struct {
TaskID string
Attempt int
LeaseExpiresAt time.Time
Workload string
Input Input
Parameters map[string]any
leaseExpiresRaw string
}
// Uploaded is the coordinator-owned metadata returned after artifact upload.
type Uploaded struct {
ArtifactID string
URI string
SHA256 string
SizeBytes int64
}
func requireString(value any, field string) (string, error) {
text, ok := value.(string)
if !ok || strings.TrimSpace(text) == "" {
return "", fmt.Errorf("%s must be a non-empty string", field)
}
return text, nil
}
func safeCoordinatorURI(value any, field string) (string, error) {
uri, err := requireString(value, field)
if err != nil {
return "", err
}
if strings.HasPrefix(uri, "/") {
// A network-path reference (//host/path) or dot segments would
// resolve to another origin; reject both.
if strings.HasPrefix(uri, "//") {
return "", fmt.Errorf("%s must be a safe coordinator path", field)
}
for _, segment := range strings.Split(uri, "/") {
if segment == ".." {
return "", fmt.Errorf("%s must be a safe coordinator path", field)
}
}
return uri, nil
}
parsed, err := url.Parse(uri)
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" {
return "", fmt.Errorf("%s must be an absolute HTTP(S) URL or coordinator path", field)
}
return uri, nil
}
func sha256Hex(value any, field string) (string, error) {
digest, err := requireString(value, field)
if err != nil {
return "", err
}
digest = strings.ToLower(digest)
if !sha256Pattern.MatchString(digest) {
return "", fmt.Errorf("%s must be a SHA-256 hex digest", field)
}
return digest, nil
}
func uuid(value any, field string) (string, error) {
text, err := requireString(value, field)
if err != nil {
return "", err
}
if !uuidPattern.MatchString(text) {
return "", fmt.Errorf("%s must be a UUID", field)
}
return strings.ToLower(text), nil
}
// ParseTask validates a claimed-task response with the same strictness as the
// Python worker's ClaimedTask.from_json.
func ParseTask(payload map[string]any) (*Task, error) {
rawInput, ok := payload["input"].(map[string]any)
if !ok {
return nil, fmt.Errorf("input must be an object")
}
rawAttempt, ok := payload["attempt"].(float64)
if !ok || rawAttempt < 1 || rawAttempt != float64(int(rawAttempt)) {
return nil, fmt.Errorf("attempt must be a positive integer")
}
taskID, err := uuid(payload["task_id"], "task_id")
if err != nil {
return nil, fmt.Errorf("invalid claimed-task response: %w", err)
}
rawLease, err := requireString(payload["lease_expires_at"], "lease_expires_at")
if err != nil {
return nil, fmt.Errorf("invalid claimed-task response: %w", err)
}
lease, err := time.Parse(time.RFC3339, rawLease)
if err != nil || lease.Location() == nil {
return nil, fmt.Errorf("lease_expires_at must include a timezone")
}
workload, err := requireString(payload["workload"], "workload")
if err != nil || !workloadPattern.MatchString(workload) {
return nil, fmt.Errorf("workload must be a canonical name")
}
uri, err := safeCoordinatorURI(rawInput["uri"], "input.uri")
if err != nil {
return nil, fmt.Errorf("invalid claimed-task response: %w", err)
}
digest, err := sha256Hex(rawInput["sha256"], "input.sha256")
if err != nil {
return nil, fmt.Errorf("invalid claimed-task response: %w", err)
}
parameters, ok := payload["parameters"].(map[string]any)
if !ok {
parameters = map[string]any{}
}
return &Task{
TaskID: taskID,
Attempt: int(rawAttempt),
LeaseExpiresAt: lease,
leaseExpiresRaw: rawLease,
Workload: workload,
Input: Input{URI: uri, SHA256: digest},
Parameters: parameters,
}, nil
}
// LeaseExpiresRaw returns the original lease timestamp string for
// round-tripping in heartbeat deadlines.
func (t *Task) LeaseExpiresRaw() string { return t.leaseExpiresRaw }
// ParseRegistered validates a registration response.
func ParseRegistered(payload map[string]any) (*RegisteredWorker, error) {
workerID, err := uuid(payload["worker_id"], "worker_id")
if err != nil {
return nil, fmt.Errorf("invalid worker registration response: %w", err)
}
interval, ok := payload["heartbeat_interval_seconds"].(float64)
if !ok || interval <= 0 {
return nil, fmt.Errorf("heartbeat_interval_seconds must be positive")
}
return &RegisteredWorker{WorkerID: workerID, HeartbeatIntervalSeconds: interval}, nil
}
// ParseUploaded validates an artifact upload response.
func ParseUploaded(payload map[string]any) (*Uploaded, error) {
artifactID, err := uuid(payload["artifact_id"], "artifact_id")
if err != nil {
return nil, fmt.Errorf("invalid artifact upload response: %w", err)
}
uri, err := safeCoordinatorURI(payload["uri"], "uri")
if err != nil {
return nil, fmt.Errorf("invalid artifact upload response: %w", err)
}
digest, err := sha256Hex(payload["sha256"], "sha256")
if err != nil {
return nil, fmt.Errorf("invalid artifact upload response: %w", err)
}
rawSize, ok := payload["size_bytes"].(float64)
if !ok || rawSize < 0 || rawSize != float64(int64(rawSize)) {
return nil, fmt.Errorf("artifact size_bytes must be a non-negative integer")
}
return &Uploaded{ArtifactID: artifactID, URI: uri, SHA256: digest, SizeBytes: int64(rawSize)}, nil
}
// TaskRunnerManifest is what the Python task entry writes on success.
type TaskRunnerManifest struct {
ArtifactPath string `json:"artifact_path"`
ContentType string `json:"content_type"`
Metrics map[string]any `json:"metrics"`
}
// Encode serializes a claim payload for /tasks/claim.
func Encode(v any) ([]byte, error) { return json.Marshal(v) }
+111
View File
@@ -0,0 +1,111 @@
package agent
import (
"testing"
"time"
)
func validTaskPayload() map[string]any {
return map[string]any{
"task_id": "11111111-1111-4111-8111-111111111111",
"attempt": 1.0,
"lease_expires_at": "2026-08-02T00:00:00Z",
"workload": "similarity-search",
"input": map[string]any{
"uri": "/tasks/11111111-1111-4111-8111-111111111111/input",
"sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
},
"parameters": map[string]any{"query_smiles": "CCO"},
}
}
func TestParseTaskAcceptsValidPayload(t *testing.T) {
task, err := ParseTask(validTaskPayload())
if err != nil {
t.Fatalf("ParseTask: %v", err)
}
if task.TaskID != "11111111-1111-4111-8111-111111111111" {
t.Errorf("task id = %q", task.TaskID)
}
if task.Attempt != 1 || task.Workload != "similarity-search" {
t.Errorf("attempt/workload = %d/%q", task.Attempt, task.Workload)
}
if task.Input.SHA256 != "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" {
t.Errorf("sha256 = %q", task.Input.SHA256)
}
if task.LeaseExpiresAt.IsZero() {
t.Error("lease must parse")
}
}
func TestParseTaskRejectsInvalidPayloads(t *testing.T) {
tests := []struct {
name string
mutate func(map[string]any)
}{
{"non-uuid task id", func(p map[string]any) { p["task_id"] = "../outside" }},
{"zero attempt", func(p map[string]any) { p["attempt"] = 0 }},
{"naive lease", func(p map[string]any) { p["lease_expires_at"] = "2026-08-02T00:00:00" }},
{"network-path uri", func(p map[string]any) {
p["input"].(map[string]any)["uri"] = "//outside.example/input"
}},
{"dot-segment uri", func(p map[string]any) {
p["input"].(map[string]any)["uri"] = "/tasks/../outside/input"
}},
{"short sha256", func(p map[string]any) {
p["input"].(map[string]any)["sha256"] = "abc"
}},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
payload := validTaskPayload()
test.mutate(payload)
if _, err := ParseTask(payload); err == nil {
t.Error("expected ParseTask to reject the payload")
}
})
}
}
func TestParseRegisteredAndUploaded(t *testing.T) {
registered, err := ParseRegistered(map[string]any{
"worker_id": "22222222-2222-4222-8222-222222222222",
"heartbeat_interval_seconds": 15.0,
})
if err != nil {
t.Fatalf("ParseRegistered: %v", err)
}
if registered.HeartbeatIntervalSeconds != 15 {
t.Errorf("interval = %v", registered.HeartbeatIntervalSeconds)
}
uploaded, err := ParseUploaded(map[string]any{
"artifact_id": "33333333-3333-4333-8333-333333333333",
"uri": "https://coordinator.example/artifacts/333/download",
"sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
"size_bytes": 12.0,
})
if err != nil {
t.Fatalf("ParseUploaded: %v", err)
}
if uploaded.SizeBytes != 12 {
t.Errorf("size = %d", uploaded.SizeBytes)
}
if _, err := ParseUploaded(map[string]any{"artifact_id": "missing"}); err == nil {
t.Error("expected invalid upload metadata to fail")
}
}
func TestLeaseHeartbeatDelayIsBelowHalfTTL(t *testing.T) {
task, err := ParseTask(validTaskPayload())
if err != nil {
t.Fatal(err)
}
task.LeaseExpiresAt = time.Now().Add(60 * time.Second)
heartbeat := newLeaseHeartbeat(task, "worker", nil, 15*time.Second)
delay := heartbeat.nextDelay()
if delay > 30*time.Second || delay <= 0 {
t.Errorf("delay = %v, want < 30s", delay)
}
}
@@ -0,0 +1,70 @@
package agent
import (
"context"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
)
// ReleaseWheelURL returns the download URL of the scimesh wheel attached to
// the GitHub release that matches the given binary version (for example
// "1.1.0-alpha.10"), plus the wheel file name. The wheel is version-locked to
// the binary so a worker's catalog always matches its task runner.
func ReleaseWheelURL(version string) (string, string, error) {
if version == "" || version == "dev" {
return "", "", fmt.Errorf("no release wheel for build %q", version)
}
filename := fmt.Sprintf("scimesh-%s-py3-none-any.whl", NormalizePEP440(version))
return fmt.Sprintf("https://github.com/emil28092005/SciMesh/releases/download/v%s/%s", version, filename), filename, nil
}
// NormalizePEP440 turns our release tag suffixes into the PEP 440 form
// setuptools uses for wheel names: 1.1.0-alpha.10 -> 1.1.0a10,
// 1.1.0-beta.2 -> 1.1.0b2, 1.1.0-rc.1 -> 1.1.0rc1. Stable tags pass through.
func NormalizePEP440(version string) string {
for from, to := range map[string]string{"-alpha.": "a", "-beta.": "b", "-rc.": "rc"} {
version = strings.ReplaceAll(version, from, to)
}
return version
}
// DownloadWheel fetches the release wheel into dir (config directory of the
// wizard / serve data dir) and returns the local path. Best-effort download
// with a generous timeout: wheels can be several MB.
func DownloadWheel(ctx context.Context, url, dir string) (string, error) {
target := filepath.Join(dir, wheelNameFromURL(url))
ctx, cancel := context.WithTimeout(ctx, 10*time.Minute)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return "", err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", fmt.Errorf("download wheel: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("download wheel: HTTP %d", resp.StatusCode)
}
//nolint:gosec // G304: target is our own config dir + a fixed wheel name
out, err := os.Create(target)
if err != nil {
return "", fmt.Errorf("download wheel: %w", err)
}
defer func() { _ = out.Close() }()
if _, err := io.Copy(out, resp.Body); err != nil {
return "", fmt.Errorf("download wheel: %w", err)
}
return target, nil
}
// wheelNameFromURL extracts the trailing file name of a wheel URL.
func wheelNameFromURL(url string) string {
return url[strings.LastIndex(url, "/")+1:]
}
@@ -0,0 +1,81 @@
package agent
import (
"context"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
)
func TestNormalizePEP440(t *testing.T) {
cases := map[string]string{
"1.1.0": "1.1.0",
"1.1.0-alpha.10": "1.1.0a10",
"1.1.0-beta.2": "1.1.0b2",
"1.1.0-rc.1": "1.1.0rc1",
"1.0.0": "1.0.0",
}
for in, want := range cases {
if got := NormalizePEP440(in); got != want {
t.Errorf("NormalizePEP440(%q) = %q, want %q", in, got, want)
}
}
}
func TestReleaseWheelURL(t *testing.T) {
url, name, err := ReleaseWheelURL("1.1.0-alpha.10")
if err != nil {
t.Fatal(err)
}
wantURL := "https://github.com/emil28092005/SciMesh/releases/download/v1.1.0-alpha.10/scimesh-1.1.0a10-py3-none-any.whl"
if url != wantURL {
t.Errorf("url = %q, want %q", url, wantURL)
}
if name != "scimesh-1.1.0a10-py3-none-any.whl" {
t.Errorf("name = %q", name)
}
// A dev build has no release wheel.
if _, _, err := ReleaseWheelURL("dev"); err == nil {
t.Error("dev build must not resolve a release wheel")
}
if _, _, err := ReleaseWheelURL(""); err == nil {
t.Error("empty version must not resolve a release wheel")
}
}
func TestDownloadWheel(t *testing.T) {
payload := []byte("fake wheel bytes")
stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write(payload)
}))
defer stub.Close()
dir := t.TempDir()
path, err := DownloadWheel(context.Background(), stub.URL+"/scimesh-1.1.0a10-py3-none-any.whl", dir)
if err != nil {
t.Fatal(err)
}
if !strings.HasSuffix(path, "scimesh-1.1.0a10-py3-none-any.whl") {
t.Errorf("path = %q", path)
}
got, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if string(got) != string(payload) {
t.Error("wheel bytes mismatch")
}
}
func TestDownloadWheelReportsHTTPErrors(t *testing.T) {
stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
}))
defer stub.Close()
if _, err := DownloadWheel(context.Background(), stub.URL+"/missing.whl", t.TempDir()); err == nil {
t.Error("404 must fail the download")
}
}
+58
View File
@@ -0,0 +1,58 @@
package agent
import (
"errors"
"fmt"
"os"
"regexp"
"strings"
)
var (
// Go's regexp (RE2) has no lookbehind, so these patterns conservatively
// anchor on the characters that typically precede a local path:
// whitespace, quotes, parens, brackets, or the start of the message.
windowsPathPattern = regexp.MustCompile(`[A-Za-z]:\\[^\s'"\],)]+`)
posixPathPattern = regexp.MustCompile(`(^|[\s'"(\[=])/(?:[^\s'"\],)]+)`)
)
// SanitizeErrorMessage keeps coordinator-visible failures useful without
// exposing local paths. It mirrors the Python worker's sanitizer: local work
// directories and absolute paths are redacted, and the message is truncated
// to 300 characters.
func SanitizeErrorMessage(message string, workDir string) string {
message = strings.ReplaceAll(message, workDir, "<worker-dir>")
message = windowsPathPattern.ReplaceAllString(message, "<path>")
message = posixPathPattern.ReplaceAllString(message, "${1}<path>")
if len(message) > 300 {
message = message[:300]
}
return message
}
// IsRetryableError classifies failures for the coordinator. Invalid scientific
// input and missing local tools are permanent; everything else (transient
// transport errors, unexpected runner failures) may be retried.
func IsRetryableError(err error) bool {
if err == nil {
return false
}
var coordinatorErr *CoordinatorError
var pathErr *os.PathError
if errors.As(err, &coordinatorErr) || errors.As(err, &pathErr) {
return false
}
return true
}
// TaskRunnerExit classifies subprocess exits.
const (
ExitPermanent = 3 // runner classified the failure as invalid input
)
func runnerExitError(exit int, stderr string) error {
if exit == ExitPermanent {
return &CoordinatorError{msg: stderr}
}
return fmt.Errorf("task runner failed with exit code %d: %s", exit, stderr)
}
@@ -0,0 +1,582 @@
// 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
install func(ctx context.Context, venvPython, pkg string) error
downloadWheel func(ctx context.Context, url, dir string) (string, error)
}
// 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
// InstallScimesh overrides the pip step of the runtime installer (tests
// substitute a fake); nil uses the real pip inside the managed venv.
InstallScimesh func(ctx context.Context, venvPython, pkg string) error
// DownloadWheel overrides the release-wheel download (tests substitute a
// fake); nil downloads from the GitHub release matching the agent version.
DownloadWheel func(ctx context.Context, url, dir string) (string, error)
}
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
}
install := opts.InstallScimesh
if install == nil {
install = installScimeshWithPip
}
downloadWheel := opts.DownloadWheel
if downloadWheel == nil {
downloadWheel = agent.DownloadWheel
}
return &Server{log: log, cfgPath: cfgPath, logPath: filepath.Join(dir, logFileName), dir: dir, sup: sup, openBrowser: open, port: port, install: install, downloadWheel: downloadWheel}
}
// 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/runtime/install", s.handleInstallRuntime)
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"`
}
// ensureVenvTaskRunner rewrites the saved config so its task runner uses the
// managed venv python when one exists and the config does not already pin one.
func (s *Server) ensureVenvTaskRunner() {
raw, err := os.ReadFile(s.cfgPath)
if err != nil {
return
}
var file agent.ConfigFile
if json.Unmarshal(raw, &file) != nil || len(file.TaskRunner) > 0 {
return
}
if venv := s.venvPython(); venv != "" {
file.TaskRunner = []string{venv, "-m", "scimesh.worker.task"}
if payload, err := json.MarshalIndent(file, "", " "); err == nil {
_ = os.WriteFile(s.cfgPath, append(payload, '\n'), 0o600)
}
}
}
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
}
// The wizard UI bakes the venv python into the runner after an install;
// an API-driven or scripted flow may not, so the server guarantees it:
// workloads execute through scimesh's task runner, which lives in the venv.
if len(file.TaskRunner) == 0 {
if venv := s.venvPython(); venv != "" {
file.TaskRunner = []string{venv, "-m", "scimesh.worker.task"}
}
}
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
}
s.ensureVenvTaskRunner()
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")
type installRuntimeRequest struct {
// ScimeshPackage overrides where the scimesh wheel comes from: a local
// wheel/index path or the PyPI name. Defaults to SCIMESH_PIP_PACKAGE, then
// to the PyPI name.
ScimeshPackage string `json:"scimesh_package"`
}
type installRuntimeResponse struct {
OK bool `json:"ok"`
Python string `json:"python,omitempty"` // venv python to use as TASK_RUNNER[0]
Installed bool `json:"installed"`
}
// handleInstallRuntime creates a managed venv next to the worker config and
// installs the scimesh package into it, so the machine needs no manual pip
// step. The venv python path is returned for the wizard to bake into the
// task runner.
func (s *Server) handleInstallRuntime(w http.ResponseWriter, r *http.Request) {
var req installRuntimeRequest
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
}
pkg := strings.TrimSpace(req.ScimeshPackage)
if pkg == "" {
pkg = os.Getenv("SCIMESH_PIP_PACKAGE")
}
if pkg == "" {
// No PyPI default on purpose: the PyPI name "scimesh" belongs to an
// unrelated project. Instead we ship the wheel in our own GitHub
// release, version-locked to this binary, and download it from there.
url, _, err := agent.ReleaseWheelURL(agent.Version)
if err != nil {
writeJSON(w, http.StatusConflict, map[string]string{
"error": "no scimesh source configured: set SCIMESH_PIP_PACKAGE to your wheel, checkout or index, then retry",
})
return
}
local, err := s.downloadWheel(r.Context(), url, s.dir)
if err != nil {
s.log.Error("download release wheel", "err", err, "url", url)
writeJSON(w, http.StatusConflict, map[string]string{
"error": "could not download the scimesh wheel for this release: " + err.Error() + ". Set SCIMESH_PIP_PACKAGE to your wheel, checkout or index and retry.",
})
return
}
pkg = local
}
python3, err := exec.LookPath("python3")
if err != nil {
writeJSON(w, http.StatusConflict, map[string]string{"error": "python3 is not installed on this machine"})
return
}
venvDir := filepath.Join(s.dir, "venv")
venvPython := filepath.Join(venvDir, "bin", "python")
if _, err := os.Stat(venvPython); err != nil {
// Windows layout: Scripts/python.exe.
if win := filepath.Join(venvDir, "Scripts", "python.exe"); stat(win) {
venvPython = win
}
}
if _, err := os.Stat(venvPython); err != nil {
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Minute)
defer cancel()
cmd := exec.CommandContext(ctx, python3, "-m", "venv", venvDir) //nolint:gosec // G204: python3 from LookPath, venvDir is our own dir
if out, err := cmd.CombinedOutput(); err != nil {
s.log.Error("create runtime venv", "err", err, "out", truncate(string(out), 500))
writeJSON(w, http.StatusConflict, map[string]string{"error": "could not create the python venv"})
return
}
}
if err := s.install(r.Context(), venvPython, pkg); err != nil {
s.log.Error("install scimesh runtime", "err", err)
writeJSON(w, http.StatusConflict, map[string]string{
"error": "pip install " + pkg + " failed: " + err.Error() +
". Set SCIMESH_PIP_PACKAGE to your scimesh wheel or index and retry.",
})
return
}
writeJSON(w, http.StatusOK, installRuntimeResponse{OK: true, Python: venvPython, Installed: true})
}
// venvPython returns the managed venv python when the runtime installer has
// created one, so the task runner can be pointed at it automatically.
func (s *Server) venvPython() string {
for _, candidate := range []string{
filepath.Join(s.dir, "venv", "bin", "python"),
filepath.Join(s.dir, "venv", "Scripts", "python.exe"),
} {
if _, err := os.Stat(candidate); err == nil {
return candidate
}
}
return ""
}
// installScimeshWithPip installs the package with the venv's own pip,
// streaming into the agent log so a long build is not silent.
func installScimeshWithPip(ctx context.Context, venvPython, pkg string) error {
pip := filepath.Join(filepath.Dir(venvPython), "pip")
if _, err := os.Stat(pip); err != nil {
pip += ".exe"
}
ctx, cancel := context.WithTimeout(ctx, 10*time.Minute)
defer cancel()
cmd := exec.CommandContext(ctx, pip, "install", pkg) //nolint:gosec // G204: pip from our venv, pkg is operator-set or a fixed default
return cmd.Run()
}
func stat(path string) bool {
_, err := os.Stat(path)
return err == nil
}
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "…"
}
@@ -0,0 +1,456 @@
package setupui
import (
"context"
"encoding/json"
"errors"
"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()
return newTestServerWithInstall(t, sup, nil)
}
func newTestServerWithInstall(t *testing.T, sup Supervisor, install func(ctx context.Context, venvPython, pkg string) error) (*Server, string) {
t.Helper()
return newTestServerWithInstallAndWheel(t, sup, install, nil)
}
func newTestServerWithInstallAndWheel(t *testing.T, sup Supervisor, install func(ctx context.Context, venvPython, pkg string) error, wheel func(ctx context.Context, url, dir string) (string, error)) (*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) {},
InstallScimesh: install,
DownloadWheel: wheel,
})
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)
}
}
func TestInstallRuntimeCreatesVenvAndReportsPython(t *testing.T) {
var installedPkg string
sup := &fakeSup{}
_, base := newTestServerWithInstall(t, sup, func(ctx context.Context, venvPython, pkg string) error {
installedPkg = pkg
// Prove the venv python path really exists by creating a marker file
// where the real venv python would be.
_ = os.MkdirAll(filepath.Dir(venvPython), 0o755)
_ = os.WriteFile(venvPython, []byte("#!/bin/sh\nexit 0\n"), 0o755)
return nil
})
rec, data := postJSON(t, base, "/api/runtime/install", map[string]any{"scimesh_package": "/wheels/scimesh.whl"})
if rec.Code != http.StatusOK || data["ok"] != true {
t.Fatalf("install: got %d %v, want 200 ok", rec.Code, data)
}
if installedPkg != "/wheels/scimesh.whl" {
t.Errorf("package = %q, want the requested wheel", installedPkg)
}
if !strings.HasSuffix(data["python"].(string), "venv/bin/python") {
t.Errorf("python = %v, want the venv python", data["python"])
}
}
func TestInstallRuntimeRequiresASource(t *testing.T) {
sup := &fakeSup{}
_, base := newTestServerWithInstall(t, sup, func(ctx context.Context, venvPython, pkg string) error {
t.Fatal("install must not run without a package source")
return nil
})
// No source anywhere (SCIMESH_PIP_PACKAGE unset, request empty): 409 with
// guidance. The PyPI name is another project, so no silent fallback.
rec, data := postJSON(t, base, "/api/runtime/install", map[string]any{})
if rec.Code != http.StatusConflict {
t.Fatalf("install without source: got %d, want 409", rec.Code)
}
if !strings.Contains(data["error"].(string), "SCIMESH_PIP_PACKAGE") {
t.Errorf("error = %v, want a hint about SCIMESH_PIP_PACKAGE", data["error"])
}
}
func TestInstallRuntimeFailureIsExplained(t *testing.T) {
sup := &fakeSup{}
_, base := newTestServerWithInstall(t, sup, func(ctx context.Context, venvPython, pkg string) error {
return errors.New("no matching distribution found")
})
rec, data := postJSON(t, base, "/api/runtime/install", map[string]any{"scimesh_package": "/wheels/scimesh.whl"})
if rec.Code != http.StatusConflict {
t.Fatalf("install failure: got %d, want 409", rec.Code)
}
if !strings.Contains(data["error"].(string), "SCIMESH_PIP_PACKAGE") {
t.Errorf("error = %v, want a hint about SCIMESH_PIP_PACKAGE", data["error"])
}
}
func TestInstallRuntimeDownloadsReleaseWheelWhenNoSource(t *testing.T) {
oldVersion := agent.Version
agent.Version = "1.1.0-alpha.10"
t.Cleanup(func() { agent.Version = oldVersion })
var downloadedURL, installedPkg string
sup := &fakeSup{}
_, base := newTestServerWithInstallAndWheel(t, sup,
func(ctx context.Context, venvPython, pkg string) error {
installedPkg = pkg
return nil
},
func(ctx context.Context, url, dir string) (string, error) {
downloadedURL = url
return filepath.Join(dir, "scimesh-1.1.0a10-py3-none-any.whl"), nil
})
rec, data := postJSON(t, base, "/api/runtime/install", map[string]any{})
if rec.Code != http.StatusOK || data["ok"] != true {
t.Fatalf("install: got %d %v, want 200 ok", rec.Code, data)
}
if !strings.Contains(downloadedURL, "releases/download/v1.1.0-alpha.10/scimesh-1.1.0a10-py3-none-any.whl") {
t.Errorf("download url = %q, want the release wheel of this version", downloadedURL)
}
if !strings.HasSuffix(installedPkg, "scimesh-1.1.0a10-py3-none-any.whl") {
t.Errorf("pip received %q, want the downloaded wheel", installedPkg)
}
}
func TestInstallRuntimeWheelDownloadFailureIsExplained(t *testing.T) {
oldVersion := agent.Version
agent.Version = "1.1.0-alpha.10"
t.Cleanup(func() { agent.Version = oldVersion })
sup := &fakeSup{}
_, base := newTestServerWithInstallAndWheel(t, sup,
func(ctx context.Context, venvPython, pkg string) error { t.Fatal("pip must not run"); return nil },
func(ctx context.Context, url, dir string) (string, error) {
return "", errors.New("HTTP 404")
})
rec, data := postJSON(t, base, "/api/runtime/install", map[string]any{})
if rec.Code != http.StatusConflict {
t.Fatalf("got %d, want 409", rec.Code)
}
if !strings.Contains(data["error"].(string), "SCIMESH_PIP_PACKAGE") {
t.Errorf("error = %v, want a SCIMESH_PIP_PACKAGE hint", data["error"])
}
}
func TestStartPinsTheVenvTaskRunner(t *testing.T) {
sup := &fakeSup{}
server, base := newTestServer(t, sup)
postJSON(t, base, "/api/config", map[string]any{
"coordinator_url": "http://coord:8080", "token": "t", "work_dir": ".",
})
// Simulate the runtime installer: create the venv python marker.
venvPython := filepath.Join(server.dir, "venv", "bin", "python")
_ = os.MkdirAll(filepath.Dir(venvPython), 0o755)
_ = os.WriteFile(venvPython, []byte("#!/bin/sh\nexit 0\n"), 0o755)
rec, _ := postJSON(t, base, "/api/start", map[string]any{})
if rec.Code != http.StatusOK {
t.Fatalf("start: got %d, want 200", rec.Code)
}
config, err := agent.LoadConfigFile(server.cfgPath)
if err != nil {
t.Fatal(err)
}
if len(config.TaskRunner) != 3 || config.TaskRunner[0] != venvPython || config.TaskRunner[1] != "-m" || config.TaskRunner[2] != "scimesh.worker.task" {
t.Errorf("task runner = %v, want the venv python runner", config.TaskRunner)
}
}
func TestSaveConfigPinsVenvRunnerWhenPresent(t *testing.T) {
server, base := newTestServer(t, &fakeSup{})
venvPython := filepath.Join(server.dir, "venv", "bin", "python")
_ = os.MkdirAll(filepath.Dir(venvPython), 0o755)
_ = os.WriteFile(venvPython, []byte("#!/bin/sh\nexit 0\n"), 0o755)
rec, _ := postJSON(t, base, "/api/config", map[string]any{
"coordinator_url": "http://coord:8080", "token": "t", "work_dir": ".",
})
if rec.Code != http.StatusOK {
t.Fatalf("config: got %d", rec.Code)
}
config, err := agent.LoadConfigFile(server.cfgPath)
if err != nil {
t.Fatal(err)
}
if len(config.TaskRunner) != 3 || config.TaskRunner[0] != venvPython {
t.Errorf("task runner = %v, want the venv python", config.TaskRunner)
}
}
@@ -0,0 +1,312 @@
<!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',venvPython:null};
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(){
const cfg={
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
};
if(state.venvPython)cfg.task_runner=[state.venvPython,'-m','scimesh.worker.task'];
return cfg;
}
$('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>
+86
View File
@@ -0,0 +1,86 @@
package agent
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
)
// TaskRunner spawns the Python task entry and returns the sealed partial
// artifact manifest it produced.
type TaskRunner struct {
command []string
}
func NewTaskRunner(command []string) *TaskRunner {
return &TaskRunner{command: command}
}
// Run executes one task: the task payload is written as JSON into the attempt
// directory, the Python entry computes and seals the partial, and the written
// manifest is parsed back. stderr is captured for failure reporting.
func (r *TaskRunner) Run(task *Task, taskDir string, manifestPath string, extraEnv []string) (*TaskRunnerManifest, error) {
if err := os.MkdirAll(taskDir, 0o750); err != nil {
return nil, err
}
payload := map[string]any{
"task_id": task.TaskID,
"attempt": task.Attempt,
"lease_expires_at": task.leaseExpiresRaw,
"workload": task.Workload,
"input": map[string]any{
"uri": task.Input.URI,
"sha256": task.Input.SHA256,
},
"parameters": task.Parameters,
}
payloadBytes, err := json.Marshal(payload)
if err != nil {
return nil, err
}
taskJSONPath := filepath.Join(taskDir, "task.json")
if err := os.WriteFile(taskJSONPath, payloadBytes, 0o600); err != nil {
return nil, err
}
args := append([]string{}, r.command[1:]...)
args = append(args,
"--task-json", taskJSONPath,
"--task-dir", taskDir,
"--output", manifestPath,
)
// #nosec G204 -- the command comes from the operator-configured TASK_RUNNER.
command := exec.CommandContext(context.Background(), r.command[0], args...)
command.Dir = taskDir
command.Env = append(os.Environ(), extraEnv...)
var stderr bytes.Buffer
command.Stderr = &stderr
if err := command.Run(); err != nil {
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
return nil, runnerExitError(exitErr.ExitCode(), stderr.String())
}
return nil, fmt.Errorf("task runner could not be started: %w", err)
}
// #nosec G304 -- the manifest path is inside the worker's own task directory.
raw, err := os.ReadFile(manifestPath)
if err != nil {
return nil, fmt.Errorf("task runner produced no result manifest")
}
var manifest TaskRunnerManifest
if err := json.Unmarshal(raw, &manifest); err != nil {
return nil, fmt.Errorf("task runner produced an invalid result manifest")
}
if manifest.ArtifactPath == "" || manifest.ContentType == "" {
return nil, fmt.Errorf("task runner produced an incomplete result manifest")
}
info, err := os.Stat(manifest.ArtifactPath)
if err != nil || !info.Mode().IsRegular() {
return nil, fmt.Errorf("task runner produced no artifact file")
}
return &manifest, nil
}
+43
View File
@@ -0,0 +1,43 @@
// Package authctx carries the authenticated requester across the transport and
// use-case layers without either one importing the other. The HTTP middleware
// stamps a Requester after verifying a user's JWT; the job use cases read it to
// record ownership and to enforce that a non-admin only touches their own jobs.
package authctx
import (
"context"
"github.com/google/uuid"
)
// Requester is the identity behind a request, derived from a verified JWT.
// A request authenticated only by the shared worker/service token carries no
// Requester at all (From returns ok=false), which is how worker traffic and
// legacy unauthenticated-user traffic stay owner-less.
type Requester struct {
UserID uuid.UUID
Role string
Verified bool
}
// IsAdmin reports whether the requester may act on any user's jobs.
func (r Requester) IsAdmin() bool { return r.Role == "admin" }
// IsTrusted reports whether workers this requester registers produce results
// the coordinator accepts without quorum. Admins and verified contributors are
// trusted; a plain unverified user is not.
func (r Requester) IsTrusted() bool { return r.IsAdmin() || r.Verified }
type ctxKey struct{}
// With returns a copy of ctx carrying r.
func With(ctx context.Context, r Requester) context.Context {
return context.WithValue(ctx, ctxKey{}, r)
}
// From returns the requester stamped by the middleware, or ok=false when the
// request was not authenticated as a user.
func From(ctx context.Context) (Requester, bool) {
r, ok := ctx.Value(ctxKey{}).(Requester)
return r, ok
}
+149
View File
@@ -0,0 +1,149 @@
// Package chunk splits a tabular input into deterministic shards. It is generic
// row splitting only — no workload semantics (SMILES, top-k) live here.
package chunk
import (
"bufio"
"bytes"
"fmt"
"io"
"strings"
)
// ErrNoRows is returned when the input has a header but no data rows: a job with
// zero tasks could never complete, so it is rejected at the source.
var ErrNoRows = fmt.Errorf("input has no data rows")
// maxShardBytes bounds the coordinator memory used by one in-progress shard.
// The uploaded file may be much larger: it is first stored on disk, then split
// in small bounded pieces. Operators can lower rowsPerShard when this limit is
// reached rather than exhausting the coordinator process.
const maxShardBytes = 64 << 20 // 64 MiB
// SplitTSV reads a header-plus-rows text stream and cuts it into shards of at
// most rowsPerShard data rows. Every shard repeats the header, so a worker can
// parse its shard in isolation. emit is called once per shard, in order, with a
// reader over that shard's bytes; the reader is valid only for the duration of
// the call.
//
// Splitting is deterministic: the same input and rowsPerShard always produce the
// same shards, byte for byte — which is what lets chunk_index refer to a stable
// piece and makes a re-run reproducible.
//
// Only one shard is buffered at a time, so memory is bounded by shard size (a
// worker-sized slice of the data), not by the size of the whole dataset.
func SplitTSV(r io.Reader, rowsPerShard int, emit func(index int, shard io.Reader) error) error {
return splitTSVLimit(r, rowsPerShard, 0, nil, emit)
}
// SplitTSVLimit behaves like SplitTSV but emits no more than maxRows data rows.
// A maxRows value of zero means unlimited. This lets an operator make a small,
// representative pipeline check without materialising a second dataset file.
func SplitTSVLimit(r io.Reader, rowsPerShard, maxRows int, emit func(index int, shard io.Reader) error) error {
return splitTSVLimit(r, rowsPerShard, maxRows, nil, emit)
}
// SplitChEMBLTSVLimit is the coordinator's scientific-upload splitter. It
// validates the two columns every local SciMesh workload requires before any
// shard task is persisted, while generic SplitTSV remains reusable for future
// non-chemistry workloads.
func SplitChEMBLTSVLimit(r io.Reader, rowsPerShard, maxRows int, emit func(index int, shard io.Reader) error) error {
return splitTSVLimit(r, rowsPerShard, maxRows, validateChEMBLHeader, emit)
}
func splitTSVLimit(r io.Reader, rowsPerShard, maxRows int, validateHeader func([]byte) error, emit func(index int, shard io.Reader) error) error {
if rowsPerShard <= 0 {
return fmt.Errorf("rowsPerShard must be positive, got %d", rowsPerShard)
}
if maxRows < 0 {
return fmt.Errorf("maxRows must be non-negative, got %d", maxRows)
}
sc := bufio.NewScanner(r)
// Allow long lines: a SMILES row can be far wider than bufio's 64 KB default.
sc.Buffer(make([]byte, 0, 64*1024), 8*1024*1024)
if !sc.Scan() {
if err := sc.Err(); err != nil {
return fmt.Errorf("read header: %w", err)
}
return ErrNoRows // completely empty input
}
header := append([]byte(nil), sc.Bytes()...)
if validateHeader != nil {
if err := validateHeader(header); err != nil {
return err
}
}
var (
buf bytes.Buffer
rows int
index int
)
// flush emits the buffered shard and resets for the next one.
flush := func() error {
if err := emit(index, bytes.NewReader(buf.Bytes())); err != nil {
return err
}
index++
buf.Reset()
rows = 0
return nil
}
for sc.Scan() {
if rows == 0 {
if len(header)+1 > maxShardBytes {
return fmt.Errorf("TSV header exceeds maximum shard size of %d bytes", maxShardBytes)
}
buf.Write(header)
buf.WriteByte('\n')
}
if buf.Len()+len(sc.Bytes())+1 > maxShardBytes {
return fmt.Errorf("shard exceeds maximum size of %d bytes; lower rowsPerShard", maxShardBytes)
}
buf.Write(sc.Bytes())
buf.WriteByte('\n')
rows++
if rows == rowsPerShard {
if err := flush(); err != nil {
return err
}
}
if maxRows > 0 && index*rowsPerShard+rows == maxRows {
break
}
}
if err := sc.Err(); err != nil {
return fmt.Errorf("read rows: %w", err)
}
// A partial final shard still has to go out.
if rows > 0 {
if err := flush(); err != nil {
return err
}
}
if index == 0 {
return ErrNoRows // header only, no data
}
return nil
}
func validateChEMBLHeader(header []byte) error {
seen := make(map[string]struct{})
for _, field := range strings.Split(strings.TrimPrefix(string(header), "\ufeff"), "\t") {
seen[field] = struct{}{}
}
if _, ok := seen["chembl_id"]; !ok {
return fmt.Errorf("TSV is missing required column chembl_id")
}
if _, ok := seen["canonical_smiles"]; !ok {
return fmt.Errorf("TSV is missing required column canonical_smiles")
}
return nil
}
+141
View File
@@ -0,0 +1,141 @@
package chunk
import (
"bytes"
"errors"
"fmt"
"io"
"strings"
"testing"
)
// collect runs SplitTSV and returns every shard as a string.
func collect(t *testing.T, input string, rowsPerShard int) []string {
t.Helper()
var shards []string
err := SplitTSV(strings.NewReader(input), rowsPerShard, func(index int, shard io.Reader) error {
b, _ := io.ReadAll(shard)
if index != len(shards) {
t.Fatalf("emit index = %d, want %d (out of order)", index, len(shards))
}
shards = append(shards, string(b))
return nil
})
if err != nil {
t.Fatalf("SplitTSV: %v", err)
}
return shards
}
func TestSplitCountsShardsAndRepeatsHeader(t *testing.T) {
input := "id\tsmiles\nA\tCC\nB\tCCC\nC\tCCCC\nD\tCCCCC\nE\tCCCCCC\n"
shards := collect(t, input, 2)
if len(shards) != 3 { // 5 rows / 2 per shard = ceil = 3
t.Fatalf("got %d shards, want 3", len(shards))
}
for i, s := range shards {
if !strings.HasPrefix(s, "id\tsmiles\n") {
t.Errorf("shard %d missing header: %q", i, s)
}
}
if shards[0] != "id\tsmiles\nA\tCC\nB\tCCC\n" {
t.Errorf("shard 0 = %q", shards[0])
}
if shards[2] != "id\tsmiles\nE\tCCCCCC\n" { // partial final shard
t.Errorf("shard 2 = %q", shards[2])
}
}
func TestSplitExactMultipleHasNoEmptyTrailingShard(t *testing.T) {
input := "h\nr1\nr2\nr3\nr4\n"
shards := collect(t, input, 2)
if len(shards) != 2 { // exactly 4/2, no empty third shard
t.Fatalf("got %d shards, want 2", len(shards))
}
}
func TestSplitIsDeterministic(t *testing.T) {
input := "h\n" + strings.Repeat("row\n", 100)
a := collect(t, input, 7)
b := collect(t, input, 7)
if fmt.Sprint(a) != fmt.Sprint(b) {
t.Error("two runs produced different shards")
}
}
func TestSplitRejectsHeaderOnly(t *testing.T) {
err := SplitTSV(strings.NewReader("id\tsmiles\n"), 10, func(int, io.Reader) error { return nil })
if !errors.Is(err, ErrNoRows) {
t.Errorf("err = %v, want ErrNoRows", err)
}
}
func TestSplitRejectsEmptyInput(t *testing.T) {
err := SplitTSV(strings.NewReader(""), 10, func(int, io.Reader) error { return nil })
if !errors.Is(err, ErrNoRows) {
t.Errorf("err = %v, want ErrNoRows", err)
}
}
func TestSplitRejectsNonPositiveSize(t *testing.T) {
err := SplitTSV(strings.NewReader("h\nr\n"), 0, func(int, io.Reader) error { return nil })
if err == nil {
t.Error("expected an error for rowsPerShard = 0")
}
}
func TestSplitPropagatesEmitError(t *testing.T) {
boom := errors.New("boom")
err := SplitTSV(strings.NewReader("h\nr1\nr2\n"), 1, func(int, io.Reader) error { return boom })
if !errors.Is(err, boom) {
t.Errorf("err = %v, want boom", err)
}
}
func TestSplitSingleShardWhenSizeExceedsRows(t *testing.T) {
shards := collect(t, "h\nr1\nr2\n", 100)
if len(shards) != 1 {
t.Fatalf("got %d shards, want 1", len(shards))
}
if shards[0] != "h\nr1\nr2\n" {
t.Errorf("shard 0 = %q", shards[0])
}
}
func TestSplitLimitUsesOnlyLeadingDataRows(t *testing.T) {
input := "h\nr1\nr2\nr3\nr4\nr5\n"
var shards []string
err := SplitTSVLimit(strings.NewReader(input), 2, 3, func(_ int, shard io.Reader) error {
b, _ := io.ReadAll(shard)
shards = append(shards, string(b))
return nil
})
if err != nil {
t.Fatal(err)
}
if got, want := strings.Join(shards, ""), "h\nr1\nr2\nh\nr3\n"; got != want {
t.Errorf("limited shards = %q, want %q", got, want)
}
}
func TestChEMBLSplitRejectsMissingRequiredColumns(t *testing.T) {
err := SplitChEMBLTSVLimit(strings.NewReader("id\tsmiles\nA\tCC\n"), 1, 0,
func(int, io.Reader) error { return nil })
if err == nil || !strings.Contains(err.Error(), "chembl_id") {
t.Errorf("err = %v, want missing-column error", err)
}
}
// The scanned bytes are reused by bufio; the shard buffer must copy them, or a
// later row would corrupt an earlier one. This guards that copy.
func TestSplitDoesNotAliasScannerBuffer(t *testing.T) {
var got bytes.Buffer
_ = SplitTSV(strings.NewReader("h\naaaa\nbbbb\n"), 2, func(_ int, shard io.Reader) error {
_, _ = io.Copy(&got, shard)
return nil
})
if want := "h\naaaa\nbbbb\n"; got.String() != want {
t.Errorf("got %q, want %q", got.String(), want)
}
}
+70
View File
@@ -0,0 +1,70 @@
package domain
import (
"time"
"github.com/google/uuid"
)
type ArtifactKind string
const (
ArtifactInput ArtifactKind = "input"
ArtifactShard ArtifactKind = "shard"
ArtifactPartialResult ArtifactKind = "partial_result"
ArtifactFinalResult ArtifactKind = "final_result"
ArtifactLog ArtifactKind = "log"
)
// Artifact is a durable file the coordinator owns, described by its metadata.
// The bytes live in blob storage under StorageKey; this struct is what the
// database persists and what every other layer reasons about.
type Artifact struct {
ID uuid.UUID
JobID uuid.UUID
TaskID *uuid.UUID // nil for a job-level input
Attempt *int // required for a partial result; nil for non-worker artifacts
Kind ArtifactKind
Filename string
StorageKey string
ContentType string
SizeBytes int64
SHA256 string
CreatedAt time.Time
}
// NewArtifact begins an artifact record. Size and checksum are unknown until the
// bytes have been streamed to storage, so they are filled in later by SetContent.
//
// StorageKey is derived from a fresh UUID, never from the client-supplied
// filename — that is what stops a "../../etc/passwd" filename from escaping the
// storage directory.
func NewArtifact(jobID uuid.UUID, taskID *uuid.UUID, kind ArtifactKind,
filename, contentType string, now time.Time) (*Artifact, error) {
if filename == "" || kind == "" {
return nil, ErrInvalidInput
}
if contentType == "" {
contentType = "application/octet-stream"
}
id := uuid.New()
return &Artifact{
ID: id,
JobID: jobID,
TaskID: taskID,
Kind: kind,
Filename: filename,
StorageKey: id.String(),
ContentType: contentType,
CreatedAt: now,
}, nil
}
// SetContent records the size and checksum measured while streaming the bytes
// into storage. Both are computed by the coordinator, never trusted from the
// client — the whole point of owning the artifact.
func (a *Artifact) SetContent(sha256 string, size int64) {
a.SHA256 = sha256
a.SizeBytes = size
}
@@ -0,0 +1,55 @@
package domain
import (
"errors"
"testing"
"github.com/google/uuid"
)
func TestNewArtifact(t *testing.T) {
jobID := uuid.New()
taskID := uuid.New()
a, err := NewArtifact(jobID, &taskID, ArtifactPartialResult, "result.csv", "text/csv", testNow)
if err != nil {
t.Fatal(err)
}
if a.JobID != jobID || a.TaskID == nil || *a.TaskID != taskID {
t.Error("ownership not recorded")
}
// Storage key is derived from the artifact id, never the filename — no path
// traversal from a hostile "../.." name.
if a.StorageKey != a.ID.String() {
t.Errorf("storage key = %q, want the artifact id", a.StorageKey)
}
if a.SizeBytes != 0 || a.SHA256 != "" {
t.Error("size and checksum are unknown until SetContent")
}
}
func TestNewArtifactDefaultsContentType(t *testing.T) {
a, err := NewArtifact(uuid.New(), nil, ArtifactInput, "data", "", testNow)
if err != nil {
t.Fatal(err)
}
if a.ContentType != "application/octet-stream" {
t.Errorf("content type = %q, want the default", a.ContentType)
}
}
func TestNewArtifactRejectsBadInput(t *testing.T) {
if _, err := NewArtifact(uuid.New(), nil, ArtifactInput, "", "text/csv", testNow); !errors.Is(err, ErrInvalidInput) {
t.Errorf("empty filename: err = %v, want ErrInvalidInput", err)
}
if _, err := NewArtifact(uuid.New(), nil, "", "f", "text/csv", testNow); !errors.Is(err, ErrInvalidInput) {
t.Errorf("empty kind: err = %v, want ErrInvalidInput", err)
}
}
func TestArtifactSetContent(t *testing.T) {
a, _ := NewArtifact(uuid.New(), nil, ArtifactShard, "shard-0.tsv", "text/csv", testNow)
a.SetContent("deadbeef", 42)
if a.SHA256 != "deadbeef" || a.SizeBytes != 42 {
t.Error("SetContent must record checksum and size")
}
}
+22
View File
@@ -0,0 +1,22 @@
package domain
import "errors"
// Business-rule violations. They live in the innermost layer because they
// describe what the rules are, not how a transport reports them: the HTTP
// adapter maps these to status codes, and nothing here knows 409 exists.
//
// Always compare with errors.Is — outer layers may wrap these with %w.
var (
ErrJobNotFound = errors.New("job not found")
ErrTaskNotFound = errors.New("task not found")
ErrWorkerNotFound = errors.New("worker not found")
ErrArtifactNotFound = errors.New("artifact not found")
ErrJobNotCancellable = errors.New("job cannot be cancelled")
ErrLeaseConflict = errors.New("task leased to another worker")
ErrStaleAttempt = errors.New("attempt does not match lease")
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")
)
+143
View File
@@ -0,0 +1,143 @@
package domain
import (
"time"
"github.com/google/uuid"
)
type JobStatus string
const (
JobPending JobStatus = "pending"
JobRunning JobStatus = "running"
JobReducing JobStatus = "reducing"
JobCompleted JobStatus = "completed"
JobFailed JobStatus = "failed"
JobCancelled JobStatus = "cancelled"
)
// Job is one user submission that fans out into one or more tasks.
type Job struct {
ID uuid.UUID
// OwnerID is the userservice user who submitted the job (JWT `sub`). nil
// when the job was created without user authentication. Not a foreign key:
// users live in a separate service/database.
OwnerID *uuid.UUID
Workload string
InputURI string // external input URI; empty for uploaded datasets
InputArtifactID *uuid.UUID // uploaded input artifact; nil for URI submissions
ResultArtifactID *uuid.UUID
Parameters map[string]any
Status JobStatus
CreatedAt time.Time
CompletedAt *time.Time
ReducerStartedAt *time.Time
ErrorCode *string
ErrorMessage *string
}
// NewUploadedJob builds a job whose input was uploaded to the coordinator. The
// job's id is generated here so the input artifact can reference it; the reverse
// link (jobs.input_artifact_id) is left unset — the input is found via the
// artifact's job_id — which also sidesteps the circular job↔artifact FK.
func NewUploadedJob(workload string, params map[string]any, now time.Time) (*Job, error) {
if workload == "" {
return nil, ErrInvalidInput
}
return &Job{
ID: uuid.New(),
Workload: workload,
Parameters: params,
Status: JobPending,
CreatedAt: now,
}, nil
}
// ChunkSpec describes one piece a job is split into. Callers build these from
// whatever chunking strategy the workload uses; the domain only validates them.
type ChunkSpec struct {
ChunkIndex int
Workload string // empty inherits the job's workload
InputURI string
InputSHA256 string
Parameters map[string]any
MaxAttempts int
}
// NewJobWithTasks builds a job together with all of its tasks, validating the
// set as a whole. Returning both from one constructor keeps the invariant
// visible: a job without tasks, or with duplicate chunk indexes, cannot exist.
func NewJobWithTasks(workload, inputURI string, params map[string]any,
chunks []ChunkSpec, now time.Time) (*Job, []*Task, error) {
if workload == "" || inputURI == "" || len(chunks) == 0 {
return nil, nil, ErrInvalidInput
}
job := &Job{
ID: uuid.New(),
Workload: workload,
InputURI: inputURI,
Parameters: params,
Status: JobPending,
CreatedAt: now,
}
seen := make(map[int]struct{}, len(chunks))
tasks := make([]*Task, 0, len(chunks))
for _, c := range chunks {
if _, dup := seen[c.ChunkIndex]; dup {
return nil, nil, ErrInvalidInput // unique (job_id, chunk_index)
}
seen[c.ChunkIndex] = struct{}{}
w := c.Workload
if w == "" {
w = workload
}
task, err := NewTask(job.ID, c.ChunkIndex, w, c.InputURI, c.InputSHA256,
c.Parameters, c.MaxAttempts, now)
if err != nil {
return nil, nil, err
}
tasks = append(tasks, task)
}
return job, tasks, nil
}
// JobProgress is the aggregate view of a job and the state of its tasks.
type JobProgress struct {
Job Job
Total int
Pending int
Leased int
Done int
Failed int
Cancelled int
}
// DeriveStatus computes what the job's status should be from its task counts,
// so the rule lives here rather than in a SQL trigger or a handler.
func (p JobProgress) DeriveStatus() JobStatus {
switch {
case p.Job.Status == JobCancelled:
return JobCancelled
case p.Job.Status == JobFailed:
// A reducer may fail after every shard has completed. That terminal
// failure must not be overwritten by an otherwise-complete task count.
return JobFailed
case p.Job.Status == JobReducing:
return JobReducing
case p.Total == 0:
return JobPending
case p.Done == p.Total:
return JobCompleted
case p.Failed > 0 && p.Done+p.Failed == p.Total:
return JobFailed
case p.Leased > 0 || p.Done > 0 || p.Failed > 0:
return JobRunning
default:
return JobPending
}
}
+142
View File
@@ -0,0 +1,142 @@
package domain
import (
"errors"
"testing"
"github.com/google/uuid"
)
func TestNewJobWithTasksBuildsBoth(t *testing.T) {
job, tasks, err := NewJobWithTasks("similarity_search", "s3://in", nil, []ChunkSpec{
{ChunkIndex: 0, InputURI: "s3://c0", InputSHA256: "a"},
{ChunkIndex: 1, InputURI: "s3://c1", InputSHA256: "b"},
}, testNow)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(tasks) != 2 {
t.Fatalf("got %d tasks, want 2", len(tasks))
}
for _, tk := range tasks {
if tk.JobID != job.ID {
t.Error("task not linked to job")
}
if tk.Workload != "similarity_search" {
t.Error("task should inherit the job workload")
}
}
if job.Status != JobPending {
t.Errorf("status = %q, want pending", job.Status)
}
}
func TestNewJobWithTasksRejectsBadInput(t *testing.T) {
good := []ChunkSpec{{ChunkIndex: 0, InputURI: "s3://c0", InputSHA256: "a"}}
cases := map[string]struct {
workload string
inputURI string
chunks []ChunkSpec
}{
"empty workload": {"", "s3://in", good},
"empty input": {"w", "", good},
"no chunks": {"w", "s3://in", nil},
"duplicate index": {"w", "s3://in", []ChunkSpec{
{ChunkIndex: 0, InputURI: "a", InputSHA256: "x"},
{ChunkIndex: 0, InputURI: "b", InputSHA256: "y"},
}},
}
for name, c := range cases {
t.Run(name, func(t *testing.T) {
if _, _, err := NewJobWithTasks(c.workload, c.inputURI, nil, c.chunks, testNow); !errors.Is(err, ErrInvalidInput) {
t.Errorf("err = %v, want ErrInvalidInput", err)
}
})
}
}
func TestNewJobWithTasksInheritsAndOverridesWorkload(t *testing.T) {
_, tasks, err := NewJobWithTasks("base", "s3://in", nil, []ChunkSpec{
{ChunkIndex: 0, InputURI: "a", InputSHA256: "x"},
{ChunkIndex: 1, InputURI: "b", InputSHA256: "y", Workload: "special"},
}, testNow)
if err != nil {
t.Fatal(err)
}
if tasks[0].Workload != "base" || tasks[1].Workload != "special" {
t.Errorf("workloads = %q, %q", tasks[0].Workload, tasks[1].Workload)
}
}
func TestDeriveStatus(t *testing.T) {
cases := []struct {
name string
p JobProgress
want JobStatus
}{
{"empty", JobProgress{Total: 0}, JobPending},
{"all pending", JobProgress{Total: 3, Pending: 3}, JobPending},
{"one leased", JobProgress{Total: 3, Pending: 2, Leased: 1}, JobRunning},
{"partly done", JobProgress{Total: 3, Pending: 1, Done: 2}, JobRunning},
{"all done", JobProgress{Total: 3, Done: 3}, JobCompleted},
{"done and failed", JobProgress{Total: 3, Done: 2, Failed: 1}, JobFailed},
{"failed but work remains", JobProgress{Total: 3, Pending: 1, Failed: 2}, JobRunning},
{"cancelled job wins over task histogram", JobProgress{Job: Job{Status: JobCancelled}, Total: 3, Done: 1, Cancelled: 2}, JobCancelled},
{"persisted reducer failure wins over completed tasks", JobProgress{Job: Job{Status: JobFailed}, Total: 3, Done: 3}, JobFailed},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := c.p.DeriveStatus(); got != c.want {
t.Errorf("DeriveStatus() = %q, want %q", got, c.want)
}
})
}
}
func TestNewUploadedJob(t *testing.T) {
job, err := NewUploadedJob("w", map[string]any{"k": 1}, testNow)
if err != nil {
t.Fatal(err)
}
if job.Status != JobPending || job.InputURI != "" {
t.Error("uploaded job should be pending with no input URI")
}
if _, err := NewUploadedJob("", nil, testNow); !errors.Is(err, ErrInvalidInput) {
t.Errorf("empty workload: err = %v, want ErrInvalidInput", err)
}
}
func TestNewShardTask(t *testing.T) {
art := uuid.New()
task, err := NewShardTask(uuid.New(), 2, "w", art, "sha", nil, 0, testNow)
if err != nil {
t.Fatal(err)
}
if task.InputArtifactID == nil || *task.InputArtifactID != art {
t.Error("shard task must reference its input artifact")
}
if task.InputURI != "" {
t.Error("shard task must not carry a URI")
}
if task.MaxAttempts != DefaultMaxAttempts {
t.Errorf("maxAttempts = %d, want default %d", task.MaxAttempts, DefaultMaxAttempts)
}
bad := []struct {
name string
art uuid.UUID
sha string
idx int
}{
{"nil artifact", uuid.Nil, "sha", 0},
{"empty sha", art, "", 0},
{"negative index", art, "sha", -1},
}
for _, c := range bad {
t.Run(c.name, func(t *testing.T) {
if _, err := NewShardTask(uuid.New(), c.idx, "w", c.art, c.sha, nil, 0, testNow); !errors.Is(err, ErrInvalidInput) {
t.Errorf("err = %v, want ErrInvalidInput", err)
}
})
}
}
+335
View File
@@ -0,0 +1,335 @@
// Package domain holds SciMesh's entities and the rules that govern them. It
// is the innermost layer: it imports nothing from this module and knows nothing
// about HTTP, SQL, or configuration. Every state transition a task can undergo
// is a method here, so the rules are unit-testable without a database.
package domain
import (
"time"
"github.com/google/uuid"
)
type TaskStatus string
const (
TaskPending TaskStatus = "pending"
TaskLeased TaskStatus = "leased"
TaskRunning TaskStatus = "running"
TaskCompleted TaskStatus = "completed"
TaskFailed TaskStatus = "failed"
TaskCancelled TaskStatus = "cancelled"
)
// ErrCodeLeaseExpired marks tasks failed by the reaper rather than by a worker.
const ErrCodeLeaseExpired = "lease_expired"
// ErrCodeQuorumFailed marks a task whose untrusted results never reached a
// verifying quorum before its attempts ran out.
const ErrCodeQuorumFailed = "quorum_failed"
// Task is one independently executable chunk of a job.
//
// Nullable columns are pointers so "no lease" stays distinguishable from
// "lease owned by the empty string" — a plain string cannot express both.
type Task struct {
ID uuid.UUID
JobID uuid.UUID
ChunkIndex int
Workload string
InputURI string // external input URI; empty for uploaded shards
InputArtifactID *uuid.UUID // coordinator-stored shard; nil for URI inputs
InputSHA256 string
Parameters map[string]any
Status TaskStatus
Attempt int
MaxAttempts int
LeaseOwner *string
LeaseExpiresAt *time.Time
ResultArtifactID *uuid.UUID
Metrics map[string]any
ErrorCode *string
ErrorMessage *string
CreatedAt time.Time
StartedAt *time.Time
CompletedAt *time.Time
Version int
}
// NewTask builds a pending task. maxAttempts <= 0 falls back to the default.
func NewTask(jobID uuid.UUID, chunkIndex int, workload, inputURI, inputSHA256 string,
params map[string]any, maxAttempts int, now time.Time) (*Task, error) {
if inputURI == "" {
return nil, ErrInvalidInput
}
if inputSHA256 == "" {
return nil, ErrInvalidInput // checksum is mandatory: workers verify inputs
}
if chunkIndex < 0 {
return nil, ErrInvalidInput
}
if maxAttempts <= 0 {
maxAttempts = DefaultMaxAttempts
}
return &Task{
ID: uuid.New(),
JobID: jobID,
ChunkIndex: chunkIndex,
Workload: workload,
InputURI: inputURI,
InputSHA256: inputSHA256,
Parameters: params,
Status: TaskPending,
Attempt: 0,
MaxAttempts: maxAttempts,
CreatedAt: now,
}, nil
}
// NewShardTask builds a pending task whose input is a coordinator-stored shard
// artifact rather than an external URI. The worker fetches it from the
// coordinator, so no InputURI is set — inputSHA256 is the shard's checksum.
func NewShardTask(jobID uuid.UUID, chunkIndex int, workload string, inputArtifactID uuid.UUID,
inputSHA256 string, params map[string]any, maxAttempts int, now time.Time) (*Task, error) {
if inputArtifactID == uuid.Nil || inputSHA256 == "" || chunkIndex < 0 {
return nil, ErrInvalidInput
}
if maxAttempts <= 0 {
maxAttempts = DefaultMaxAttempts
}
return &Task{
ID: uuid.New(),
JobID: jobID,
ChunkIndex: chunkIndex,
Workload: workload,
InputArtifactID: &inputArtifactID,
InputSHA256: inputSHA256,
Parameters: params,
Status: TaskPending,
Attempt: 0,
MaxAttempts: maxAttempts,
CreatedAt: now,
}, nil
}
// DefaultMaxAttempts applies when a task does not specify its own ceiling.
const DefaultMaxAttempts = 3
// CanRetry reports whether any attempts remain.
func (t *Task) CanRetry() bool { return t.Attempt < t.MaxAttempts }
// IsLeaseHeldBy reports whether worker currently holds this task at attempt.
func (t *Task) IsLeaseHeldBy(worker string, attempt int, now time.Time) bool {
return t.LeaseOwner != nil && t.LeaseExpiresAt != nil && now.Before(*t.LeaseExpiresAt) &&
*t.LeaseOwner == worker && t.Attempt == attempt &&
(t.Status == TaskLeased || t.Status == TaskRunning)
}
// AsClaimed projects the task into the trimmed view handed to a worker:
// everything needed to execute, nothing it has no business seeing.
func (t *Task) AsClaimed() ClaimedTask {
ct := ClaimedTask{
TaskID: t.ID,
JobID: t.JobID,
ChunkIndex: t.ChunkIndex,
Workload: t.Workload,
InputURI: t.InputURI,
InputArtifactID: t.InputArtifactID,
InputSHA256: t.InputSHA256,
Parameters: t.Parameters,
Attempt: t.Attempt,
}
if t.LeaseOwner != nil {
ct.LeaseOwner = *t.LeaseOwner
}
if t.LeaseExpiresAt != nil {
ct.LeaseExpiresAt = *t.LeaseExpiresAt
}
return ct
}
// verifyLease is the guard every worker-driven transition shares: the caller
// must own the lease and reference the attempt it was granted.
func (t *Task) verifyLease(worker string, attempt int, now time.Time) error {
// A task is worker-owned while leased or running: the first heartbeat moves
// it from leased to running, but ownership rules are identical for both.
if t.Status != TaskLeased && t.Status != TaskRunning {
return ErrTaskNotLeased
}
if t.LeaseOwner == nil || *t.LeaseOwner != worker {
return ErrLeaseConflict
}
if t.Attempt != attempt {
return ErrStaleAttempt
}
if t.LeaseExpiresAt == nil || !now.Before(*t.LeaseExpiresAt) {
return ErrLeaseConflict
}
return nil
}
// RenewLease extends the lease of the worker that holds it. The first heartbeat
// also acknowledges start, moving the task from leased to running.
func (t *Task) RenewLease(worker string, attempt int, now, until time.Time) error {
if err := t.verifyLease(worker, attempt, now); err != nil {
return err
}
t.LeaseExpiresAt = &until
if t.Status == TaskLeased {
t.Status = TaskRunning
}
t.Version++
return nil
}
// CompleteWith records a successful result.
//
// Idempotency comes first deliberately: a worker whose network dropped will
// retry the same manifest, and that must succeed rather than trip the lease
// check on a task the coordinator already finished. A *different* manifest for
// an already-completed task is a genuine conflict.
func (t *Task) CompleteWith(resultArtifactID uuid.UUID, metrics map[string]any,
worker string, attempt int, now time.Time) error {
if resultArtifactID == uuid.Nil {
return ErrInvalidInput
}
if t.Status == TaskCompleted {
if t.Attempt == attempt && t.ResultArtifactID != nil && *t.ResultArtifactID == resultArtifactID {
return nil // same attempt, same artifact — replay of a successful call
}
return ErrResultConflict
}
if err := t.verifyLease(worker, attempt, now); err != nil {
return err
}
t.Status = TaskCompleted
t.ResultArtifactID = &resultArtifactID
t.Metrics = metrics
t.CompletedAt = &now
t.LeaseOwner = nil
t.LeaseExpiresAt = nil
t.ErrorCode = nil
t.ErrorMessage = nil
t.Version++
return nil
}
// ReleaseAfterVote returns an untrusted worker's task to the queue after its
// result was recorded as a quorum vote but quorum was not yet reached, so a
// different owner can compute it independently. When no attempts remain the task
// fails: its untrusted results could not be verified.
func (t *Task) ReleaseAfterVote(worker string, attempt int, now time.Time) error {
if t.Status == TaskCompleted {
return nil // settled by a concurrent quorum
}
if err := t.verifyLease(worker, attempt, now); err != nil {
return err
}
t.LeaseOwner = nil
t.LeaseExpiresAt = nil
t.Version++
if t.CanRetry() {
t.Status = TaskPending
return nil
}
code, msg := ErrCodeQuorumFailed, "untrusted results did not reach quorum"
t.ErrorCode = &code
t.ErrorMessage = &msg
t.Status = TaskFailed
t.CompletedAt = &now
return nil
}
// Fail records a worker-reported failure. A retryable failure with attempts
// left returns the task to the queue; otherwise it terminates as failed.
func (t *Task) Fail(worker string, attempt int, code, message string, retryable bool, now time.Time) error {
if err := t.verifyLease(worker, attempt, now); err != nil {
return err
}
t.ErrorCode = &code
t.ErrorMessage = &message
t.LeaseOwner = nil
t.LeaseExpiresAt = nil
t.Version++
if retryable && t.CanRetry() {
t.Status = TaskPending
return nil
}
t.Status = TaskFailed
t.CompletedAt = &now
return nil
}
// ExpireLease is applied by the reaper when a lease elapses without a
// heartbeat: requeue while attempts remain, otherwise fail terminally.
func (t *Task) ExpireLease(now time.Time) {
// Both a leased and a running task can go silent and must be reclaimed.
if t.Status != TaskLeased && t.Status != TaskRunning {
return
}
t.LeaseOwner = nil
t.LeaseExpiresAt = nil
t.Version++
if t.CanRetry() {
t.Status = TaskPending
return
}
code, msg := ErrCodeLeaseExpired, "lease expired after the final attempt"
t.ErrorCode = &code
t.ErrorMessage = &msg
t.Status = TaskFailed
t.CompletedAt = &now
}
// Cancel prevents any further worker transition for a task that has not
// reached a terminal result. A cancelled lease deliberately becomes invalid:
// a worker still running locally must not upload or complete after its job was
// stopped by the operator.
func (t *Task) Cancel(now time.Time) bool {
if t.Status == TaskCompleted || t.Status == TaskFailed || t.Status == TaskCancelled {
return false
}
t.Status = TaskCancelled
t.LeaseOwner = nil
t.LeaseExpiresAt = nil
t.ErrorCode = nil
t.ErrorMessage = nil
t.CompletedAt = &now
t.Version++
return true
}
// ClaimedTask is the worker-facing projection of a leased task. Input is either
// an external URI or a coordinator-stored shard (InputArtifactID set); the
// transport turns the latter into a coordinator download URL.
type ClaimedTask struct {
TaskID uuid.UUID
JobID uuid.UUID
ChunkIndex int
Workload string
InputURI string
InputArtifactID *uuid.UUID
InputSHA256 string
Parameters map[string]any
Attempt int
LeaseOwner string
LeaseExpiresAt time.Time
}
// ResultManifest is a completed task's output, ordered for the stitcher. It
// points at the coordinator-owned result artifact rather than a worker URI.
type ResultManifest struct {
TaskID uuid.UUID
ChunkIndex int
ResultArtifactID uuid.UUID
Metrics map[string]any
}
+253
View File
@@ -0,0 +1,253 @@
package domain
import (
"errors"
"testing"
"time"
"github.com/google/uuid"
)
var (
testNow = time.Date(2026, 7, 21, 12, 0, 0, 0, time.UTC)
testLater = testNow.Add(time.Hour)
testWorker = "worker-1"
testResult = uuid.New()
testResultAlt = uuid.New()
)
// leasedTask builds a task already leased to testWorker at the given attempt.
func leasedTask(attempt, maxAttempts int) *Task {
owner := testWorker
expires := testLater
return &Task{
ID: uuid.New(),
JobID: uuid.New(),
Status: TaskLeased,
Attempt: attempt,
MaxAttempts: maxAttempts,
LeaseOwner: &owner,
LeaseExpiresAt: &expires,
}
}
func TestCompleteWithRecordsResult(t *testing.T) {
task := leasedTask(1, 3)
if err := task.CompleteWith(testResult, nil, testWorker, 1, testNow); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if task.Status != TaskCompleted {
t.Errorf("status = %q, want completed", task.Status)
}
if task.LeaseOwner != nil || task.LeaseExpiresAt != nil {
t.Error("lease must be released on completion")
}
if task.CompletedAt == nil || !task.CompletedAt.Equal(testNow) {
t.Error("completed_at must be stamped")
}
}
// A worker whose network dropped retries the same manifest; that must succeed
// rather than fail on the lease it has already given up.
func TestCompleteWithIsIdempotentForSameManifest(t *testing.T) {
task := leasedTask(1, 3)
if err := task.CompleteWith(testResult, nil, testWorker, 1, testNow); err != nil {
t.Fatalf("first call: %v", err)
}
versionAfterFirst := task.Version
if err := task.CompleteWith(testResult, nil, testWorker, 1, testLater); err != nil {
t.Fatalf("replay must be idempotent, got %v", err)
}
if task.Version != versionAfterFirst {
t.Error("replay must not mutate the task")
}
}
func TestCompleteWithRejectsDifferentManifest(t *testing.T) {
task := leasedTask(1, 3)
if err := task.CompleteWith(testResult, nil, testWorker, 1, testNow); err != nil {
t.Fatalf("first call: %v", err)
}
err := task.CompleteWith(testResultAlt, nil, testWorker, 1, testLater)
if !errors.Is(err, ErrResultConflict) {
t.Errorf("err = %v, want ErrResultConflict", err)
}
}
func TestCompleteWithRejectsForeignWorker(t *testing.T) {
task := leasedTask(1, 3)
err := task.CompleteWith(testResult, nil, "worker-2", 1, testNow)
if !errors.Is(err, ErrLeaseConflict) {
t.Errorf("err = %v, want ErrLeaseConflict", err)
}
}
func TestCompleteWithRejectsStaleAttempt(t *testing.T) {
task := leasedTask(2, 3) // task is on attempt 2
err := task.CompleteWith(testResult, nil, testWorker, 1, testNow) // worker thinks it is 1
if !errors.Is(err, ErrStaleAttempt) {
t.Errorf("err = %v, want ErrStaleAttempt", err)
}
}
func TestFailRequeuesWhileAttemptsRemain(t *testing.T) {
task := leasedTask(1, 3)
if err := task.Fail(testWorker, 1, "boom", "exploded", true, testNow); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if task.Status != TaskPending {
t.Errorf("status = %q, want pending", task.Status)
}
if task.LeaseOwner != nil {
t.Error("lease must be released so another worker can claim it")
}
}
func TestFailTerminatesOnFinalAttempt(t *testing.T) {
task := leasedTask(3, 3) // no attempts left
if err := task.Fail(testWorker, 3, "boom", "exploded", true, testNow); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if task.Status != TaskFailed {
t.Errorf("status = %q, want failed", task.Status)
}
}
func TestFailIsTerminalWhenNotRetryable(t *testing.T) {
task := leasedTask(1, 3) // attempts remain, but the error is fatal
if err := task.Fail(testWorker, 1, "bad_input", "checksum mismatch", false, testNow); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if task.Status != TaskFailed {
t.Errorf("status = %q, want failed", task.Status)
}
}
// This is the MVP acceptance criterion: a dead worker must not strand its task.
func TestExpireLeaseRequeuesWhileAttemptsRemain(t *testing.T) {
task := leasedTask(1, 3)
task.ExpireLease(testNow)
if task.Status != TaskPending {
t.Errorf("status = %q, want pending", task.Status)
}
if task.LeaseOwner != nil || task.LeaseExpiresAt != nil {
t.Error("expired lease must be cleared")
}
}
func TestExpireLeaseFailsAfterFinalAttempt(t *testing.T) {
task := leasedTask(3, 3)
task.ExpireLease(testNow)
if task.Status != TaskFailed {
t.Errorf("status = %q, want failed", task.Status)
}
if task.ErrorCode == nil || *task.ErrorCode != ErrCodeLeaseExpired {
t.Error("expected a lease_expired error code")
}
}
func TestExpireLeaseIgnoresUnleasedTasks(t *testing.T) {
task := &Task{Status: TaskCompleted, Attempt: 1, MaxAttempts: 3}
task.ExpireLease(testNow)
if task.Status != TaskCompleted {
t.Errorf("status = %q, completed tasks must be untouched", task.Status)
}
}
func TestCancelInvalidatesLeaseButPreservesTerminalTask(t *testing.T) {
task := leasedTask(1, 3)
if !task.Cancel(testNow) {
t.Fatal("leased task should be cancelled")
}
if task.Status != TaskCancelled || task.LeaseOwner != nil || task.LeaseExpiresAt != nil {
t.Errorf("cancelled task = %+v", task)
}
if task.Cancel(testLater) {
t.Error("cancelled task must not be changed twice")
}
completed := &Task{Status: TaskCompleted}
if completed.Cancel(testNow) {
t.Error("completed task must remain terminal")
}
}
func TestFirstHeartbeatMovesLeasedToRunning(t *testing.T) {
task := leasedTask(1, 3)
until := testLater.Add(time.Hour)
if err := task.RenewLease(testWorker, 1, testNow, until); err != nil {
t.Fatal(err)
}
if task.Status != TaskRunning {
t.Errorf("status = %q, want running after first heartbeat", task.Status)
}
// A second heartbeat keeps it running.
if err := task.RenewLease(testWorker, 1, testNow, until); err != nil {
t.Fatal(err)
}
if task.Status != TaskRunning {
t.Errorf("status = %q, want running", task.Status)
}
}
func TestRunningTaskCanBeCompletedAndExpired(t *testing.T) {
// Complete works from running.
task := leasedTask(1, 3)
_ = task.RenewLease(testWorker, 1, testNow, testLater) // -> running
if err := task.CompleteWith(testResult, nil, testWorker, 1, testNow); err != nil {
t.Errorf("complete from running: %v", err)
}
// Expire reclaims a running task too.
task2 := leasedTask(1, 3)
_ = task2.RenewLease(testWorker, 1, testNow, testLater) // -> running
task2.ExpireLease(testLater)
if task2.Status != TaskPending {
t.Errorf("status = %q, want pending after a running lease expires", task2.Status)
}
}
func TestRenewLeaseExtendsOnlyForHolder(t *testing.T) {
task := leasedTask(1, 3)
until := testLater.Add(time.Hour)
if err := task.RenewLease(testWorker, 1, testNow, until); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !task.LeaseExpiresAt.Equal(until) {
t.Error("lease must be extended")
}
if err := task.RenewLease("worker-2", 1, testNow, until); !errors.Is(err, ErrLeaseConflict) {
t.Errorf("err = %v, want ErrLeaseConflict", err)
}
}
func TestExpiredLeaseRejectsRenewalCompletionAndFailure(t *testing.T) {
task := leasedTask(1, 3)
expired := testLater.Add(time.Nanosecond)
if err := task.RenewLease(testWorker, 1, expired, expired.Add(time.Minute)); !errors.Is(err, ErrLeaseConflict) {
t.Errorf("renew expired lease: err = %v, want ErrLeaseConflict", err)
}
if err := task.CompleteWith(testResult, nil, testWorker, 1, expired); !errors.Is(err, ErrLeaseConflict) {
t.Errorf("complete expired lease: err = %v, want ErrLeaseConflict", err)
}
if err := task.Fail(testWorker, 1, "timeout", "expired", true, expired); !errors.Is(err, ErrLeaseConflict) {
t.Errorf("fail expired lease: err = %v, want ErrLeaseConflict", err)
}
}
+65
View File
@@ -0,0 +1,65 @@
package domain
import (
"time"
"github.com/google/uuid"
)
type WorkerStatus string
const (
WorkerOnline WorkerStatus = "online"
WorkerBusy WorkerStatus = "busy"
WorkerOffline WorkerStatus = "offline"
)
// WorkerTrust says whether a worker's results are accepted directly or must
// clear quorum cross-checking.
type WorkerTrust string
const (
// WorkerTrusted — lab machine (shared token) or a verified/admin contributor.
WorkerTrusted WorkerTrust = "trusted"
// WorkerUntrusted — a plain enthusiast; results are quarantined until quorum.
WorkerUntrusted WorkerTrust = "untrusted"
)
// Worker is a registered process/machine allowed to claim tasks. Its
// capabilities are the allowlisted workload names it can run; the coordinator
// never hands it a task outside that set.
type Worker struct {
ID uuid.UUID
Name string
Capabilities []string
Status WorkerStatus
// OwnerID is the userservice user who registered this worker; nil for a
// worker registered with the shared service token.
OwnerID *uuid.UUID
// TrustLevel decides whether this worker's results need quorum.
TrustLevel WorkerTrust
LastHeartbeatAt time.Time
CreatedAt time.Time
UpdatedAt time.Time
}
// NewWorker registers a worker. A worker with no capabilities could never be
// handed a task, so an empty set is rejected rather than silently stored.
//
// Trust defaults to WorkerTrusted (the shared-token lab worker); the caller
// overrides it for a volunteer registered through the userservice.
func NewWorker(name string, capabilities []string, now time.Time) (*Worker, error) {
if len(capabilities) == 0 {
return nil, ErrInvalidInput
}
return &Worker{
ID: uuid.New(),
Name: name,
Capabilities: capabilities,
Status: WorkerOnline,
TrustLevel: WorkerTrusted,
LastHeartbeatAt: now,
CreatedAt: now,
UpdatedAt: now,
}, nil
}
@@ -0,0 +1,31 @@
package domain
import (
"errors"
"testing"
)
func TestNewWorker(t *testing.T) {
w, err := NewWorker("lab-01", []string{"similarity_search"}, testNow)
if err != nil {
t.Fatal(err)
}
if w.Status != WorkerOnline {
t.Errorf("status = %q, want online", w.Status)
}
if w.ID.String() == "" {
t.Error("worker must get an id")
}
if !w.LastHeartbeatAt.Equal(testNow) || !w.CreatedAt.Equal(testNow) {
t.Error("timestamps must be stamped")
}
}
func TestNewWorkerRejectsNoCapabilities(t *testing.T) {
if _, err := NewWorker("lab-01", nil, testNow); !errors.Is(err, ErrInvalidInput) {
t.Errorf("err = %v, want ErrInvalidInput", err)
}
if _, err := NewWorker("lab-01", []string{}, testNow); !errors.Is(err, ErrInvalidInput) {
t.Errorf("empty slice: err = %v, want ErrInvalidInput", err)
}
}
+13
View File
@@ -0,0 +1,13 @@
// Clock: the real implementation of the usecase.Clock port. It lives out here
// because reading the system clock is infrastructure; tests substitute a fixed one.
package infra
import "time"
type System struct{}
func NewClock() System { return System{} }
// Now returns UTC so every timestamp the coordinator writes is comparable
// regardless of the host's timezone.
func (System) Now() time.Time { return time.Now().UTC() }
+260
View File
@@ -0,0 +1,260 @@
// Config: coordinator settings, read only from the environment, so the same
// binary behaves identically in CI, local, and prod.
package infra
import (
"errors"
"fmt"
"io/fs"
"math"
"os"
"path/filepath"
"strconv"
"time"
"github.com/joho/godotenv"
)
// defaultEnvFile is loaded by Load unless ENV_FILE points elsewhere.
const defaultEnvFile = ".env"
type Config struct {
// HTTP listen address, e.g. ":8080".
Addr string
// PostgreSQL connection string (pgx format / libpq URL).
DatabaseURL string
// Shared bearer token workers must present. Empty disables auth (dev only).
Token string
// Local operator UI credential. Empty disables the embedded UI entirely.
UIToken string
// Shared HS256 secret used to verify userservice-issued JWTs. When set, a
// submitter may authenticate with a JWT (in addition to workers using the
// shared token) and their jobs are stamped with owner_id. Empty disables
// user-JWT auth entirely — the pre-userservice behaviour. Must match the
// userservice's JWT_SECRET.
JWTSecret string
// Base URL of the userservice, e.g. http://userservice:8081. When set
// together with JWTSecret, the operator UI authenticates via userservice
// login/registration (cookie session) instead of the static UI_AUTH_TOKEN
// basic auth. Empty keeps the basic-auth UI.
UserserviceURL string
// Browser-facing base URLs used to render the "add your machine" command on
// the UI. They must be reachable from a user's own machine, which is not
// necessarily the in-cluster address the coordinator uses for UserserviceURL.
// PublicCoordinatorURL empty lets the page fall back to its own origin;
// PublicUserserviceURL empty falls back to UserserviceURL.
PublicCoordinatorURL string
PublicUserserviceURL string
// Minimum log level: debug, info, warn, error.
LogLevel string
// Path to a rotated log file. Empty logs to stdout only.
LogFile string
// Directory where artifact bytes are stored.
StorageDir string
// Directory of the built MkDocs site (site/) served at /ui/docs/. Empty
// disables the docs route; the UI shows a hint page instead.
DocsDir string
// Upper bound on an uploaded dataset or artifact body, in bytes.
MaxUploadBytes int64
// Connection pool upper bound.
DBMaxConns int32
// How long to keep retrying the initial database connection at startup
// before giving up. Covers a Postgres container that is still booting.
DBConnectTimeout time.Duration
// Per-request context timeout applied to handlers and DB calls.
RequestTimeout time.Duration
// Suggested heartbeat cadence returned to workers on registration.
HeartbeatInterval time.Duration
// Default lease length handed out on claim.
LeaseDuration time.Duration
// Default attempt ceiling for newly created tasks.
DefaultMaxAttempts int
// How many distinct owners must agree on an untrusted result before it is
// accepted (trusted workers are accepted directly).
QuorumSize int
// How often the background lease-reaper runs.
ReaperInterval time.Duration
// A worker silent for longer than this is marked offline by the reaper.
WorkerOfflineAfter time.Duration
// Whether the binary applies its embedded schema migrations on startup.
// On by default so a downloaded binary provisions its own database; set
// AUTO_MIGRATE=false when an operator manages migrations out of band.
AutoMigrate bool
// 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
// or malformed, so a misconfigured process never limps along half-wired.
//
// A .env file (path overridable via ENV_FILE) is loaded first as a local-dev
// convenience. It only fills variables the environment does not already define.
func LoadConfig() (Config, error) {
envFile := os.Getenv("ENV_FILE")
if envFile == "" {
envFile = defaultEnvFile
}
// godotenv.Load never overwrites variables already present in the
// environment, so an orchestrator's values always beat the file. A missing
// file is expected in production, where env vars are injected directly.
if err := godotenv.Load(envFile); err != nil && !errors.Is(err, fs.ErrNotExist) {
return Config{}, fmt.Errorf("load env file %q: %w", envFile, err)
}
cfg := Config{
Addr: getEnv("COORDINATOR_ADDR", ":8080"),
DatabaseURL: os.Getenv("DATABASE_URL"),
// COORDINATOR_TOKEN is the contract name; WORKER_AUTH_TOKEN is the
// former name, still honoured so existing .env files keep working.
Token: getEnv("COORDINATOR_TOKEN", os.Getenv("WORKER_AUTH_TOKEN")),
UIToken: os.Getenv("UI_AUTH_TOKEN"),
JWTSecret: os.Getenv("JWT_SECRET"),
UserserviceURL: os.Getenv("USERSERVICE_URL"),
PublicCoordinatorURL: os.Getenv("PUBLIC_COORDINATOR_URL"),
PublicUserserviceURL: getEnv("PUBLIC_USERSERVICE_URL", os.Getenv("USERSERVICE_URL")),
LogLevel: getEnv("LOG_LEVEL", "info"),
LogFile: os.Getenv("LOG_FILE"),
StorageDir: getEnv("COORDINATOR_STORAGE_DIR", "./data"),
DocsDir: os.Getenv("SCIMESH_DOCS_DIR"),
MaxUploadBytes: 1 << 30, // 1 GiB
DBMaxConns: 10,
DBConnectTimeout: 30 * time.Second,
RequestTimeout: 15 * time.Second,
HeartbeatInterval: 15 * time.Second,
LeaseDuration: 2 * time.Minute,
DefaultMaxAttempts: 3,
QuorumSize: 2,
ReaperInterval: 30 * time.Second,
WorkerOfflineAfter: 1 * time.Minute,
}
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")
}
// A short secret makes the HMAC brute-forceable; refuse a weak one rather
// than verify tokens against it.
if cfg.JWTSecret != "" && len(cfg.JWTSecret) < 32 {
return Config{}, fmt.Errorf("JWT_SECRET must be at least 32 bytes")
}
var err error
if cfg.DBMaxConns, err = getEnvInt32("DB_MAX_CONNS", cfg.DBMaxConns); err != nil {
return Config{}, err
}
if cfg.DBConnectTimeout, err = getEnvDuration("DB_CONNECT_TIMEOUT", cfg.DBConnectTimeout); err != nil {
return Config{}, err
}
if cfg.MaxUploadBytes, err = getEnvInt64("MAX_UPLOAD_BYTES", cfg.MaxUploadBytes); err != nil {
return Config{}, err
}
if cfg.RequestTimeout, err = getEnvDuration("REQUEST_TIMEOUT", cfg.RequestTimeout); err != nil {
return Config{}, err
}
if cfg.HeartbeatInterval, err = getEnvDuration("HEARTBEAT_INTERVAL", cfg.HeartbeatInterval); err != nil {
return Config{}, err
}
if cfg.LeaseDuration, err = getEnvDuration("LEASE_DURATION", cfg.LeaseDuration); err != nil {
return Config{}, err
}
if cfg.ReaperInterval, err = getEnvDuration("REAPER_INTERVAL", cfg.ReaperInterval); err != nil {
return Config{}, err
}
if cfg.WorkerOfflineAfter, err = getEnvDuration("WORKER_OFFLINE_AFTER", cfg.WorkerOfflineAfter); err != nil {
return Config{}, err
}
if cfg.DefaultMaxAttempts, err = getEnvInt("DEFAULT_MAX_ATTEMPTS", cfg.DefaultMaxAttempts); err != nil {
return Config{}, err
}
if cfg.QuorumSize, err = getEnvInt("QUORUM_SIZE", cfg.QuorumSize); err != nil {
return Config{}, err
}
if cfg.QuorumSize < 1 {
return Config{}, fmt.Errorf("QUORUM_SIZE must be positive")
}
if cfg.DefaultMaxAttempts < 1 {
return Config{}, fmt.Errorf("DEFAULT_MAX_ATTEMPTS must be positive")
}
cfg.AutoMigrate = true
if raw := os.Getenv("AUTO_MIGRATE"); raw != "" {
parsed, err := strconv.ParseBool(raw)
if err != nil {
return Config{}, fmt.Errorf("AUTO_MIGRATE must be true or false")
}
cfg.AutoMigrate = parsed
}
return cfg, nil
}
func getEnv(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
func getEnvInt(key string, def int) (int, error) {
v := os.Getenv(key)
if v == "" {
return def, nil
}
n, err := strconv.Atoi(v)
if err != nil {
return 0, fmt.Errorf("%s: %w", key, err)
}
return n, nil
}
func getEnvInt32(key string, def int32) (int32, error) {
n, err := getEnvInt(key, int(def))
if err != nil {
return 0, err
}
// On 64-bit builds int is wider than int32, so an oversized value would
// wrap silently — DB_MAX_CONNS=2147483648 becoming a negative pool size.
if n < math.MinInt32 || n > math.MaxInt32 {
return 0, fmt.Errorf("%s: %d is out of range for int32", key, n)
}
return int32(n), nil
}
func getEnvInt64(key string, def int64) (int64, error) {
v := os.Getenv(key)
if v == "" {
return def, nil
}
n, err := strconv.ParseInt(v, 10, 64)
if err != nil {
return 0, fmt.Errorf("%s: %w", key, err)
}
return n, nil
}
func getEnvDuration(key string, def time.Duration) (time.Duration, error) {
v := os.Getenv(key)
if v == "" {
return def, nil
}
d, err := time.ParseDuration(v)
if err != nil {
return 0, fmt.Errorf("%s: %w", key, err)
}
return d, nil
}
+45
View File
@@ -0,0 +1,45 @@
package infra
import (
"path/filepath"
"strings"
"testing"
)
func TestLoadConfigRejectsSharedUIAndWorkerToken(t *testing.T) {
t.Setenv("ENV_FILE", filepath.Join(t.TempDir(), "missing.env"))
t.Setenv("DATABASE_URL", "postgres://test")
t.Setenv("COORDINATOR_TOKEN", "shared-secret")
t.Setenv("UI_AUTH_TOKEN", "shared-secret")
_, err := LoadConfig()
if err == nil || !strings.Contains(err.Error(), "must differ") {
t.Fatalf("LoadConfig error = %v, want distinct-token error", err)
}
}
func TestLoadConfigAllowsDistinctUIAndWorkerTokens(t *testing.T) {
t.Setenv("ENV_FILE", filepath.Join(t.TempDir(), "missing.env"))
t.Setenv("DATABASE_URL", "postgres://test")
t.Setenv("COORDINATOR_TOKEN", "worker-secret")
t.Setenv("UI_AUTH_TOKEN", "ui-secret")
cfg, err := LoadConfig()
if err != nil {
t.Fatalf("LoadConfig: %v", err)
}
if cfg.Token != "worker-secret" || cfg.UIToken != "ui-secret" {
t.Fatalf("unexpected tokens: %+v", cfg)
}
}
func TestLoadConfigRejectsNonPositiveDefaultMaxAttempts(t *testing.T) {
t.Setenv("ENV_FILE", filepath.Join(t.TempDir(), "missing.env"))
t.Setenv("DATABASE_URL", "postgres://test")
t.Setenv("DEFAULT_MAX_ATTEMPTS", "0")
_, err := LoadConfig()
if err == nil || !strings.Contains(err.Error(), "DEFAULT_MAX_ATTEMPTS") {
t.Fatalf("LoadConfig error = %v, want default-attempt validation", err)
}
}
+65
View File
@@ -0,0 +1,65 @@
// DB: the PostgreSQL connection pool.
package infra
import (
"context"
"log/slog"
"time"
"github.com/cenkalti/backoff/v4"
"github.com/jackc/pgx/v5/pgxpool"
)
// NewPool builds the single shared pool. The caller owns its lifetime and must
// Close() it on shutdown.
func NewPool(ctx context.Context, cfg Config, log *slog.Logger) (*pgxpool.Pool, error) {
poolCfg, err := pgxpool.ParseConfig(cfg.DatabaseURL)
if err != nil {
return nil, err
}
poolCfg.MaxConns = cfg.DBMaxConns
pool, err := pgxpool.NewWithConfig(ctx, poolCfg)
if err != nil {
return nil, err
}
// pgxpool.New is lazy, so a ping is needed to actually reach the server.
// It is retried because at startup — especially under docker-compose, where
// the coordinator can boot before Postgres is accepting connections — a
// service should wait for its database rather than crash-loop.
if err := pingWithRetry(ctx, pool, cfg.DBConnectTimeout, log); err != nil {
pool.Close()
return nil, err
}
return pool, nil
}
// pingWithRetry waits for the database to accept connections, backing off
// between attempts until the budget elapses or ctx is cancelled.
//
// Unlike the transaction retry in storage/postgres, this retries *any* ping
// error: at startup a "connection refused" is the expected, retryable state,
// not an anomaly.
func pingWithRetry(ctx context.Context, pool *pgxpool.Pool, budget time.Duration, log *slog.Logger) error {
b := backoff.NewExponentialBackOff()
b.InitialInterval = 200 * time.Millisecond
b.MaxInterval = 3 * time.Second
b.MaxElapsedTime = budget
attempt := 0
return backoff.RetryNotify(
func() error {
// A bounded per-attempt timeout so one hung dial cannot eat the
// whole budget in a single try.
pingCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
return pool.Ping(pingCtx)
},
backoff.WithContext(b, ctx),
func(err error, next time.Duration) {
attempt++
log.Warn("database not ready, retrying",
"attempt", attempt, "retry_in", next.String(), "err", err)
},
)
}
+65
View File
@@ -0,0 +1,65 @@
package infra
import (
"fmt"
"io"
"log/slog"
"os"
"path/filepath"
"strings"
"gopkg.in/natefinch/lumberjack.v2"
)
// NewLogger builds the process logger.
//
// It always writes JSON to stdout, so `docker logs` and any 12-factor log
// collector keep working. When LogFile is set it *also* writes to a
// size-rotated file, so logs survive a container rebuild instead of vanishing
// with the previous stdout stream. Rotation is delegated to lumberjack rather
// than hand-rolled.
//
// The returned Closer flushes and closes the file; call it on shutdown.
func NewLogger(cfg Config) (*slog.Logger, io.Closer, error) {
opts := &slog.HandlerOptions{Level: parseLevel(cfg.LogLevel)}
var (
out io.Writer = os.Stdout
closer io.Closer = noopCloser{}
)
if cfg.LogFile != "" {
if err := os.MkdirAll(filepath.Dir(cfg.LogFile), 0o750); err != nil {
return nil, nil, fmt.Errorf("create log directory: %w", err)
}
rotator := &lumberjack.Logger{
Filename: cfg.LogFile,
MaxSize: 50, // megabytes before a rotation
MaxBackups: 5, // keep this many rotated files
MaxAge: 30, // days
Compress: true,
}
// Tee to both: the console stays live while the file is the durable copy.
out = io.MultiWriter(os.Stdout, rotator)
closer = rotator
}
return slog.New(slog.NewJSONHandler(out, opts)), closer, nil
}
func parseLevel(s string) slog.Level {
switch strings.ToLower(strings.TrimSpace(s)) {
case "debug":
return slog.LevelDebug
case "warn", "warning":
return slog.LevelWarn
case "error":
return slog.LevelError
default:
return slog.LevelInfo
}
}
type noopCloser struct{}
func (noopCloser) Close() error { return nil }
+74
View File
@@ -0,0 +1,74 @@
// Server: the HTTP listener and the background lease reaper, both shut down
// cleanly on a signal.
package infra
import (
"context"
"errors"
"log/slog"
"net/http"
"time"
)
const shutdownGrace = 15 * time.Second
// Run serves handler until ctx is cancelled, then drains in-flight requests.
func RunServer(ctx context.Context, log *slog.Logger, addr string, handler http.Handler) error {
srv := &http.Server{
Addr: addr,
Handler: handler,
ReadHeaderTimeout: 5 * time.Second,
}
// Buffered so this goroutine can exit even when nobody reads the channel
// (the ctx.Done branch below) — an unbuffered send would leak it forever.
errCh := make(chan error, 1)
go func() {
log.Info("coordinator listening", "addr", addr)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
errCh <- err
}
}()
select {
case err := <-errCh:
return err
case <-ctx.Done():
log.Info("shutdown signal received")
}
// A fresh context: ctx is already cancelled, and reusing it would abort the
// very requests we are trying to let finish.
shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownGrace)
defer cancel()
return srv.Shutdown(shutdownCtx)
}
// RunReaper periodically reclaims tasks whose lease elapsed, so a worker that
// died without a heartbeat cannot strand its task in 'leased' forever.
// RunPeriodic invokes fn on an interval until ctx is done, logging how many rows
// each tick affected. It backs the background reapers (expired leases, offline
// workers) — each is a set-based UPDATE that is safe to run repeatedly and
// concurrently across coordinators.
func RunPeriodic(ctx context.Context, log *slog.Logger, name string, interval time.Duration,
fn func(context.Context) (int64, error)) {
t := time.NewTicker(interval)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
n, err := fn(ctx)
if err != nil {
log.Debug(name+" skipped", "err", err)
continue
}
if n > 0 {
log.Info(name, "count", n)
}
}
}
}
+463
View File
@@ -0,0 +1,463 @@
// Package memstore holds in-memory implementations of the usecase ports for
// tests: they exercise use-case orchestration without a database or filesystem.
// The real invariants that depend on Postgres (SKIP LOCKED, row locking) are
// covered separately by the integration tests.
package memstore
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"io"
"sort"
"sync"
"time"
"github.com/google/uuid"
"github.com/emil28092005/SciMesh/coordinator/internal/domain"
"github.com/emil28092005/SciMesh/coordinator/internal/usecase"
)
// Clock returns a fixed, advanceable time.
type Clock struct{ t time.Time }
func NewClock(t time.Time) *Clock { return &Clock{t: t} }
func (c *Clock) Now() time.Time { return c.t }
func (c *Clock) Advance(d time.Duration) { c.t = c.t.Add(d) }
// Tx is a no-op transaction manager: the in-memory stores need no atomicity to
// be observed, so it simply runs the function.
type Tx struct{}
func (Tx) WithinTx(ctx context.Context, fn func(ctx context.Context) error) error { return fn(ctx) }
// --- TaskRepo ------------------------------------------------------------
type TaskRepo struct {
mu sync.Mutex
tasks map[uuid.UUID]*domain.Task
}
func NewTaskRepo() *TaskRepo { return &TaskRepo{tasks: map[uuid.UUID]*domain.Task{}} }
var _ usecase.TaskRepository = (*TaskRepo)(nil)
// clone returns a copy so a caller's mutations do not touch stored state until
// Update — mirroring how a repository hands back detached entities.
func clone(t *domain.Task) *domain.Task { cp := *t; return &cp }
func (r *TaskRepo) put(t *domain.Task) {
r.mu.Lock()
defer r.mu.Unlock()
r.tasks[t.ID] = clone(t)
}
func (r *TaskRepo) ClaimNext(ctx context.Context, f usecase.ClaimFilter) (*domain.Task, error) {
r.mu.Lock()
defer r.mu.Unlock()
var cands []*domain.Task
for _, t := range r.tasks {
if t.Status != domain.TaskPending || t.Attempt >= t.MaxAttempts {
continue
}
if len(f.Workloads) > 0 && !contains(f.Workloads, t.Workload) {
continue
}
cands = append(cands, t)
}
if len(cands) == 0 {
return nil, nil
}
sort.Slice(cands, func(i, j int) bool {
if cands[i].CreatedAt.Equal(cands[j].CreatedAt) {
return cands[i].ChunkIndex < cands[j].ChunkIndex
}
return cands[i].CreatedAt.Before(cands[j].CreatedAt)
})
t := cands[0]
t.Status = domain.TaskLeased
t.Attempt++
owner := f.Owner
t.LeaseOwner = &owner
t.LeaseExpiresAt = &f.LeaseUntil
if t.StartedAt == nil {
t.StartedAt = &f.Now
}
t.Version++
return clone(t), nil
}
func (r *TaskRepo) Get(ctx context.Context, id uuid.UUID) (*domain.Task, error) {
r.mu.Lock()
defer r.mu.Unlock()
t, ok := r.tasks[id]
if !ok {
return nil, domain.ErrTaskNotFound
}
return clone(t), nil
}
func (r *TaskRepo) GetForUpdate(ctx context.Context, id uuid.UUID) (*domain.Task, error) {
return r.Get(ctx, id)
}
func (r *TaskRepo) Update(ctx context.Context, t *domain.Task) error {
r.mu.Lock()
defer r.mu.Unlock()
stored, ok := r.tasks[t.ID]
if !ok || stored.Version != t.Version-1 {
return domain.ErrLeaseConflict // vanished or advanced under us
}
r.tasks[t.ID] = clone(t)
return nil
}
func (r *TaskRepo) InsertBatch(ctx context.Context, tasks []*domain.Task) error {
for _, t := range tasks {
r.put(t)
}
return nil
}
func (r *TaskRepo) ListCompleted(ctx context.Context, jobID uuid.UUID) ([]*domain.Task, error) {
r.mu.Lock()
defer r.mu.Unlock()
var out []*domain.Task
for _, t := range r.tasks {
if t.JobID == jobID && t.Status == domain.TaskCompleted {
out = append(out, clone(t))
}
}
sort.Slice(out, func(i, j int) bool { return out[i].ChunkIndex < out[j].ChunkIndex })
return out, nil
}
func (r *TaskRepo) CountByStatus(ctx context.Context, jobID uuid.UUID) (map[domain.TaskStatus]int, error) {
r.mu.Lock()
defer r.mu.Unlock()
counts := map[domain.TaskStatus]int{}
for _, t := range r.tasks {
if t.JobID == jobID {
counts[t.Status]++
}
}
return counts, nil
}
func (r *TaskRepo) CancelByJob(_ context.Context, jobID uuid.UUID, now time.Time) (int64, error) {
r.mu.Lock()
defer r.mu.Unlock()
var cancelled int64
for _, task := range r.tasks {
if task.JobID == jobID && task.Cancel(now) {
cancelled++
}
}
return cancelled, nil
}
func (r *TaskRepo) ExpireLeases(ctx context.Context, now time.Time) ([]uuid.UUID, error) {
r.mu.Lock()
defer r.mu.Unlock()
affected := make([]uuid.UUID, 0)
for _, t := range r.tasks {
if (t.Status == domain.TaskLeased || t.Status == domain.TaskRunning) &&
t.LeaseExpiresAt != nil && t.LeaseExpiresAt.Before(now) {
t.ExpireLease(now)
affected = append(affected, t.JobID)
}
}
return affected, nil
}
// --- JobRepo -------------------------------------------------------------
type JobRepo struct {
mu sync.Mutex
jobs map[uuid.UUID]*domain.Job
}
func NewJobRepo() *JobRepo { return &JobRepo{jobs: map[uuid.UUID]*domain.Job{}} }
var _ usecase.JobRepository = (*JobRepo)(nil)
func (r *JobRepo) Insert(ctx context.Context, j *domain.Job) error {
r.mu.Lock()
defer r.mu.Unlock()
cp := *j
r.jobs[j.ID] = &cp
return nil
}
func (r *JobRepo) Get(ctx context.Context, id uuid.UUID) (*domain.Job, error) {
r.mu.Lock()
defer r.mu.Unlock()
j, ok := r.jobs[id]
if !ok {
return nil, domain.ErrJobNotFound
}
cp := *j
return &cp, nil
}
func (r *JobRepo) UpdateStatus(ctx context.Context, id uuid.UUID, status domain.JobStatus, completedAt *time.Time) error {
r.mu.Lock()
defer r.mu.Unlock()
j, ok := r.jobs[id]
if !ok {
return domain.ErrJobNotFound
}
j.Status = status
j.CompletedAt = completedAt
return nil
}
func (r *JobRepo) ClaimReduction(_ context.Context, id uuid.UUID, startedAt time.Time) (bool, error) {
r.mu.Lock()
defer r.mu.Unlock()
j, ok := r.jobs[id]
if !ok {
return false, domain.ErrJobNotFound
}
if j.Status != domain.JobReducing || j.ReducerStartedAt != nil {
return false, nil
}
j.ReducerStartedAt = &startedAt
return true, nil
}
func (r *JobRepo) CompleteWithResult(_ context.Context, id, resultArtifactID uuid.UUID, completedAt time.Time) error {
r.mu.Lock()
defer r.mu.Unlock()
j, ok := r.jobs[id]
if !ok {
return domain.ErrJobNotFound
}
j.ResultArtifactID = &resultArtifactID
j.Status = domain.JobCompleted
j.CompletedAt = &completedAt
j.ReducerStartedAt = nil
j.ErrorCode = nil
j.ErrorMessage = nil
return nil
}
func (r *JobRepo) FailReduction(_ context.Context, id uuid.UUID, code, message string, completedAt time.Time) error {
r.mu.Lock()
defer r.mu.Unlock()
j, ok := r.jobs[id]
if !ok {
return domain.ErrJobNotFound
}
j.Status = domain.JobFailed
j.CompletedAt = &completedAt
j.ReducerStartedAt = nil
j.ErrorCode = &code
j.ErrorMessage = &message
return nil
}
// --- WorkerRepo ----------------------------------------------------------
type WorkerRepo struct {
mu sync.Mutex
workers map[uuid.UUID]*domain.Worker
}
func NewWorkerRepo() *WorkerRepo { return &WorkerRepo{workers: map[uuid.UUID]*domain.Worker{}} }
var _ usecase.WorkerRepository = (*WorkerRepo)(nil)
func (r *WorkerRepo) Insert(ctx context.Context, w *domain.Worker) error {
r.mu.Lock()
defer r.mu.Unlock()
cp := *w
r.workers[w.ID] = &cp
return nil
}
func (r *WorkerRepo) Get(ctx context.Context, id uuid.UUID) (*domain.Worker, error) {
r.mu.Lock()
defer r.mu.Unlock()
w, ok := r.workers[id]
if !ok {
return nil, domain.ErrWorkerNotFound
}
cp := *w
return &cp, nil
}
func (r *WorkerRepo) Touch(ctx context.Context, id uuid.UUID, at time.Time) error {
r.mu.Lock()
defer r.mu.Unlock()
if w, ok := r.workers[id]; ok {
w.LastHeartbeatAt = at
w.Status = domain.WorkerOnline
}
return nil
}
func (r *WorkerRepo) MarkStaleOffline(ctx context.Context, cutoff time.Time) (int64, error) {
r.mu.Lock()
defer r.mu.Unlock()
var n int64
for _, w := range r.workers {
if w.Status != domain.WorkerOffline && w.LastHeartbeatAt.Before(cutoff) {
w.Status = domain.WorkerOffline
n++
}
}
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 {
mu sync.Mutex
arts map[uuid.UUID]*domain.Artifact
}
func NewArtifactRepo() *ArtifactRepo { return &ArtifactRepo{arts: map[uuid.UUID]*domain.Artifact{}} }
var _ usecase.ArtifactRepository = (*ArtifactRepo)(nil)
func (r *ArtifactRepo) Insert(ctx context.Context, a *domain.Artifact) error {
r.mu.Lock()
defer r.mu.Unlock()
cp := *a
r.arts[a.ID] = &cp
return nil
}
func (r *ArtifactRepo) Get(ctx context.Context, id uuid.UUID) (*domain.Artifact, error) {
r.mu.Lock()
defer r.mu.Unlock()
a, ok := r.arts[id]
if !ok {
return nil, domain.ErrArtifactNotFound
}
cp := *a
return &cp, nil
}
func (r *ArtifactRepo) FindPartialResult(_ context.Context, taskID uuid.UUID, attempt int) (*domain.Artifact, error) {
r.mu.Lock()
defer r.mu.Unlock()
for _, a := range r.arts {
if a.TaskID != nil && *a.TaskID == taskID && a.Kind == domain.ArtifactPartialResult &&
a.Attempt != nil && *a.Attempt == attempt {
return cloneArtifact(a), nil
}
}
return nil, nil
}
func cloneArtifact(a *domain.Artifact) *domain.Artifact {
cp := *a
return &cp
}
// --- BlobStore -----------------------------------------------------------
type BlobStore struct {
mu sync.Mutex
blobs map[string][]byte
}
func NewBlobStore() *BlobStore { return &BlobStore{blobs: map[string][]byte{}} }
var _ usecase.BlobStore = (*BlobStore)(nil)
func (b *BlobStore) Put(ctx context.Context, key string, r io.Reader) (string, int64, error) {
data, err := io.ReadAll(r)
if err != nil {
return "", 0, err
}
sum := sha256.Sum256(data)
b.mu.Lock()
b.blobs[key] = data
b.mu.Unlock()
return hex.EncodeToString(sum[:]), int64(len(data)), nil
}
func (b *BlobStore) Open(ctx context.Context, key string) (io.ReadCloser, error) {
b.mu.Lock()
defer b.mu.Unlock()
data, ok := b.blobs[key]
if !ok {
return nil, domain.ErrArtifactNotFound
}
return io.NopCloser(bytes.NewReader(data)), nil
}
func (b *BlobStore) Delete(ctx context.Context, key string) error {
b.mu.Lock()
defer b.mu.Unlock()
delete(b.blobs, key)
return nil
}
// Has reports whether a blob exists — handy for asserting cleanup in tests.
func (b *BlobStore) Has(key string) bool {
b.mu.Lock()
defer b.mu.Unlock()
_, ok := b.blobs[key]
return ok
}
func contains(ss []string, s string) bool {
for _, x := range ss {
if x == s {
return true
}
}
return false
}
// TaskResultRepo is an in-memory usecase.TaskResultRepository: one vote per
// (task, owner).
type TaskResultRepo struct {
mu sync.Mutex
votes map[uuid.UUID]map[uuid.UUID]string // taskID -> ownerID -> sha256
}
func NewTaskResultRepo() *TaskResultRepo {
return &TaskResultRepo{votes: make(map[uuid.UUID]map[uuid.UUID]string)}
}
func (r *TaskResultRepo) RecordVote(_ context.Context, taskID, ownerID uuid.UUID, sha256 string, _ uuid.UUID) error {
r.mu.Lock()
defer r.mu.Unlock()
if r.votes[taskID] == nil {
r.votes[taskID] = make(map[uuid.UUID]string)
}
r.votes[taskID][ownerID] = sha256
return nil
}
func (r *TaskResultRepo) CountAgreeing(_ context.Context, taskID uuid.UUID, sha256 string) (int, error) {
r.mu.Lock()
defer r.mu.Unlock()
n := 0
for _, s := range r.votes[taskID] {
if s == sha256 {
n++
}
}
return n, nil
}
+145
View File
@@ -0,0 +1,145 @@
package memstore
import (
"context"
"sort"
"github.com/google/uuid"
"github.com/emil28092005/SciMesh/coordinator/internal/domain"
"github.com/emil28092005/SciMesh/coordinator/internal/usecase"
)
// UIReadRepo is the in-memory read projection used by HTTP/UI tests.
type UIReadRepo struct {
jobs *JobRepo
tasks *TaskRepo
workers *WorkerRepo
artifacts *ArtifactRepo
}
func NewUIReadRepo(j *JobRepo, t *TaskRepo, w *WorkerRepo, a *ArtifactRepo) *UIReadRepo {
return &UIReadRepo{j, t, w, a}
}
var _ usecase.UIReadRepository = (*UIReadRepo)(nil)
func (r *UIReadRepo) GetJob(ctx context.Context, id uuid.UUID) (*domain.Job, error) {
return r.jobs.Get(ctx, id)
}
func (r *UIReadRepo) ListJobs(_ context.Context, owner *uuid.UUID, limit int) ([]domain.Job, error) {
if limit < 1 || limit > 100 {
return nil, domain.ErrInvalidInput
}
r.jobs.mu.Lock()
defer r.jobs.mu.Unlock()
out := make([]domain.Job, 0, len(r.jobs.jobs))
for _, job := range r.jobs.jobs {
if owner != nil && (job.OwnerID == nil || *job.OwnerID != *owner) {
continue
}
out = append(out, *job)
}
sort.Slice(out, func(i, j int) bool {
if out[i].CreatedAt.Equal(out[j].CreatedAt) {
return out[i].ID.String() > out[j].ID.String()
}
return out[i].CreatedAt.After(out[j].CreatedAt)
})
if len(out) > limit {
out = out[:limit]
}
return out, nil
}
func (r *UIReadRepo) ListTasksByJob(_ context.Context, jobID uuid.UUID) ([]domain.Task, error) {
r.tasks.mu.Lock()
defer r.tasks.mu.Unlock()
out := []domain.Task{}
for _, task := range r.tasks.tasks {
if task.JobID == jobID {
out = append(out, *clone(task))
}
}
sort.Slice(out, func(i, j int) bool { return out[i].ChunkIndex < out[j].ChunkIndex })
return out, nil
}
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))
for _, id := range jobIDs {
tasks, err := r.ListTasksByJob(ctx, id)
if err != nil {
return nil, err
}
out[id] = tasks
}
return out, nil
}
func (r *UIReadRepo) ListWorkers(_ context.Context, limit int) ([]domain.Worker, error) {
if limit < 1 || limit > 100 {
return nil, domain.ErrInvalidInput
}
r.workers.mu.Lock()
defer r.workers.mu.Unlock()
out := []domain.Worker{}
for _, worker := range r.workers.workers {
copy := *worker
copy.Capabilities = append([]string(nil), worker.Capabilities...)
out = append(out, copy)
}
sortWorkers(out)
if len(out) > limit {
out = out[:limit]
}
return out, nil
}
func (r *UIReadRepo) ListWorkersByOwner(_ context.Context, owner uuid.UUID, limit int) ([]domain.Worker, error) {
if limit < 1 || limit > 100 {
return nil, domain.ErrInvalidInput
}
r.workers.mu.Lock()
defer r.workers.mu.Unlock()
out := []domain.Worker{}
for _, worker := range r.workers.workers {
if worker.OwnerID == nil || *worker.OwnerID != owner {
continue
}
copy := *worker
copy.Capabilities = append([]string(nil), worker.Capabilities...)
out = append(out, copy)
}
sortWorkers(out)
if len(out) > limit {
out = out[:limit]
}
return out, nil
}
// sortWorkers orders workers most-recently-seen first, breaking ties on id so
// the order is deterministic across calls.
func sortWorkers(out []domain.Worker) {
sort.Slice(out, func(i, j int) bool {
if out[i].LastHeartbeatAt.Equal(out[j].LastHeartbeatAt) {
return out[i].ID.String() > out[j].ID.String()
}
return out[i].LastHeartbeatAt.After(out[j].LastHeartbeatAt)
})
}
func (r *UIReadRepo) ListArtifactsByJob(_ context.Context, jobID uuid.UUID) ([]domain.Artifact, error) {
r.artifacts.mu.Lock()
defer r.artifacts.mu.Unlock()
out := []domain.Artifact{}
for _, artifact := range r.artifacts.arts {
if artifact.JobID == jobID {
out = append(out, *artifact)
}
}
sort.Slice(out, func(i, j int) bool {
if out[i].CreatedAt.Equal(out[j].CreatedAt) {
return out[i].ID.String() < out[j].ID.String()
}
return out[i].CreatedAt.Before(out[j].CreatedAt)
})
return out, nil
}
+66
View File
@@ -0,0 +1,66 @@
package metrics
import (
"context"
"time"
"github.com/prometheus/client_golang/prometheus"
)
// Stats is a point-in-time snapshot of the coordinator's domain state: counts of
// tasks, jobs, and workers keyed by their status. Maps are expected to be
// zero-filled by the provider so every known status is always present, giving
// the dashboard flat zero lines instead of gaps.
type Stats struct {
Tasks map[string]int
Jobs map[string]int
Workers map[string]int
}
// StatsFunc returns the current snapshot. It is called on every scrape, so it
// must be a cheap aggregate query.
type StatsFunc func(context.Context) (Stats, error)
// RegisterBusiness registers a collector that reports domain-state gauges
// (scimesh_tasks/jobs/workers by status) sourced from collect on each scrape.
// Deriving the gauges at scrape time keeps them fresh without a background
// goroutine, and a failed query simply yields no samples for that scrape.
func (m *Metrics) RegisterBusiness(collect StatsFunc) {
m.reg.MustRegister(&businessCollector{
collect: collect,
tasks: prometheus.NewDesc("scimesh_tasks", "Tasks by status.", []string{"status"}, nil),
jobs: prometheus.NewDesc("scimesh_jobs", "Jobs by status.", []string{"status"}, nil),
workers: prometheus.NewDesc("scimesh_workers", "Workers by status.", []string{"status"}, nil),
})
}
type businessCollector struct {
collect StatsFunc
tasks, jobs, workers *prometheus.Desc
}
func (c *businessCollector) Describe(ch chan<- *prometheus.Desc) {
ch <- c.tasks
ch <- c.jobs
ch <- c.workers
}
func (c *businessCollector) Collect(ch chan<- prometheus.Metric) {
// A bounded query so one slow scrape cannot stall Prometheus.
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
s, err := c.collect(ctx)
if err != nil {
return // no samples this scrape; Prometheus keeps the last value
}
emit(ch, c.tasks, s.Tasks)
emit(ch, c.jobs, s.Jobs)
emit(ch, c.workers, s.Workers)
}
func emit(ch chan<- prometheus.Metric, desc *prometheus.Desc, counts map[string]int) {
for status, n := range counts {
ch <- prometheus.MustNewConstMetric(desc, prometheus.GaugeValue, float64(n), status)
}
}
@@ -0,0 +1,51 @@
package metrics
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func scrape(t *testing.T, m *Metrics) string {
t.Helper()
rec := httptest.NewRecorder()
req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, "/metrics", nil)
m.Handler().ServeHTTP(rec, req)
return rec.Body.String()
}
func TestBusinessCollectorEmitsGauges(t *testing.T) {
m := New()
m.RegisterBusiness(func(context.Context) (Stats, error) {
return Stats{
Tasks: map[string]int{"pending": 3, "running": 1, "completed": 0},
Jobs: map[string]int{"running": 2},
Workers: map[string]int{"online": 4},
}, nil
})
body := scrape(t, m)
for _, want := range []string{
`scimesh_tasks{status="pending"} 3`,
`scimesh_tasks{status="completed"} 0`,
`scimesh_jobs{status="running"} 2`,
`scimesh_workers{status="online"} 4`,
} {
if !strings.Contains(body, want) {
t.Errorf("metrics missing %q\n%s", want, body)
}
}
}
func TestBusinessCollectorSkipsOnError(t *testing.T) {
m := New()
m.RegisterBusiness(func(context.Context) (Stats, error) {
return Stats{}, errors.New("db down")
})
if strings.Contains(scrape(t, m), "scimesh_tasks") {
t.Error("a failed snapshot must emit no business samples")
}
}
+112
View File
@@ -0,0 +1,112 @@
// Package metrics exposes Prometheus instrumentation for the coordinator: an
// HTTP RED middleware (rate, errors, duration) plus the standard Go runtime and
// process collectors, all on a private registry so nothing leaks in from global
// state.
package metrics
import (
"net/http"
"regexp"
"strconv"
"strings"
"time"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/collectors"
"github.com/prometheus/client_golang/prometheus/promhttp"
)
type Metrics struct {
reg *prometheus.Registry
requests *prometheus.CounterVec
duration *prometheus.HistogramVec
}
// New builds the registry and registers the runtime, process, and HTTP metrics.
func New() *Metrics {
reg := prometheus.NewRegistry()
reg.MustRegister(
collectors.NewGoCollector(),
collectors.NewProcessCollector(collectors.ProcessCollectorOpts{}),
)
requests := prometheus.NewCounterVec(prometheus.CounterOpts{
Namespace: "scimesh",
Subsystem: "http",
Name: "requests_total",
Help: "HTTP requests, labelled by method, normalized route, and status.",
}, []string{"method", "route", "status"})
duration := prometheus.NewHistogramVec(prometheus.HistogramOpts{
Namespace: "scimesh",
Subsystem: "http",
Name: "request_duration_seconds",
Help: "HTTP request duration in seconds.",
Buckets: prometheus.DefBuckets,
}, []string{"method", "route"})
reg.MustRegister(requests, duration)
return &Metrics{reg: reg, requests: requests, duration: duration}
}
// Handler serves the metrics in Prometheus text format.
func (m *Metrics) Handler() http.Handler {
return promhttp.HandlerFor(m.reg, promhttp.HandlerOpts{})
}
// Registry exposes the registry so callers can register extra collectors.
func (m *Metrics) Registry() *prometheus.Registry { return m.reg }
// Middleware records one request into the RED metrics. It normalizes the path
// so per-id routes collapse to a single low-cardinality label.
func (m *Metrics) Middleware(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)
route := normalizeRoute(r.URL.Path)
m.requests.WithLabelValues(r.Method, route, strconv.Itoa(rec.status)).Inc()
m.duration.WithLabelValues(r.Method, route).Observe(time.Since(start).Seconds())
})
}
type statusRecorder struct {
http.ResponseWriter
status int
}
func (s *statusRecorder) WriteHeader(code int) {
s.status = code
s.ResponseWriter.WriteHeader(code)
}
var uuidRe = regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`)
// normalizeRoute collapses uuid and numeric path segments to {id}, keeping the
// route label cardinality bounded (otherwise every job/task id would be its own
// time series).
func normalizeRoute(path string) string {
if path == "" {
return "/"
}
segs := strings.Split(path, "/")
for i, s := range segs {
if s == "" {
continue
}
if uuidRe.MatchString(s) || isAllDigits(s) {
segs[i] = "{id}"
}
}
return strings.Join(segs, "/")
}
func isAllDigits(s string) bool {
for _, r := range s {
if r < '0' || r > '9' {
return false
}
}
return s != ""
}
@@ -0,0 +1,47 @@
package metrics
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestNormalizeRoute(t *testing.T) {
cases := map[string]string{
"/health": "/health",
"/jobs/3f2504e0-4f89-41d3-9a0c-0305e82c3301": "/jobs/{id}",
"/tasks/3f2504e0-4f89-41d3-9a0c-0305e82c3301/result": "/tasks/{id}/result",
"/ui/jobs/12345": "/ui/jobs/{id}",
"/": "/",
}
for in, want := range cases {
if got := normalizeRoute(in); got != want {
t.Errorf("normalizeRoute(%q) = %q, want %q", in, got, want)
}
}
}
func TestMiddlewareAndHandler(t *testing.T) {
m := New()
h := m.Middleware(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusCreated)
}))
req, _ := http.NewRequestWithContext(context.Background(), http.MethodPost, "/jobs/3f2504e0-4f89-41d3-9a0c-0305e82c3301", nil)
h.ServeHTTP(httptest.NewRecorder(), req)
// Scrape and confirm the request was recorded under the normalized route.
rec := httptest.NewRecorder()
greq, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, "/metrics", nil)
m.Handler().ServeHTTP(rec, greq)
body := rec.Body.String()
if !strings.Contains(body, `scimesh_http_requests_total{method="POST",route="/jobs/{id}",status="201"}`) {
t.Errorf("requests_total not recorded as expected; body:\n%s", body)
}
if !strings.Contains(body, "go_goroutines") {
t.Error("Go runtime collector not registered")
}
}
+57
View File
@@ -0,0 +1,57 @@
// Package reducer contains deterministic, coordinator-side result reductions.
package reducer
import (
"bytes"
"encoding/csv"
"errors"
"fmt"
"io"
)
// ReduceOrderedConcat concatenates worker partial tables in shard order into a
// single table with one header. Every partial must carry the same header as the
// first partial and rows of the same width; anything else fails the job closed.
func ReduceOrderedConcat(partials []io.Reader) ([]byte, error) {
var out bytes.Buffer
writer := csv.NewWriter(&out)
var firstHeader []string
for _, partial := range partials {
reader := csv.NewReader(partial)
header, err := reader.Read()
if err != nil {
return nil, fmt.Errorf("read partial header: %w", err)
}
if len(header) == 0 {
return nil, fmt.Errorf("partial result has an empty header")
}
if firstHeader == nil {
firstHeader = header
if err := writer.Write(header); err != nil {
return nil, err
}
} else if !equalStrings(header, firstHeader) {
return nil, fmt.Errorf("partial result has an inconsistent header")
}
for {
row, err := reader.Read()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
return nil, fmt.Errorf("read partial row: %w", err)
}
if len(row) != len(header) {
return nil, fmt.Errorf("partial result has a row with an inconsistent width")
}
if err := writer.Write(row); err != nil {
return nil, err
}
}
}
writer.Flush()
if err := writer.Error(); err != nil {
return nil, err
}
return out.Bytes(), nil
}
@@ -0,0 +1,64 @@
package reducer
import (
"io"
"strings"
"testing"
)
func TestReduceOrderedConcatJoinsPartialsInOrderWithOneHeader(t *testing.T) {
first := strings.NewReader("chembl_id,canonical_smiles\nA,CC\nB,CCC\n")
second := strings.NewReader("chembl_id,canonical_smiles\nC,CCCC\n")
output, err := ReduceOrderedConcat([]io.Reader{first, second})
if err != nil {
t.Fatal(err)
}
want := "chembl_id,canonical_smiles\nA,CC\nB,CCC\nC,CCCC\n"
if string(output) != want {
t.Fatalf("output = %q, want %q", output, want)
}
}
func TestReduceOrderedConcatIsDeterministicAcrossInputOrder(t *testing.T) {
left := strings.NewReader("id,rows\nA,1\nB,2\n")
right := strings.NewReader("id,rows\nC,3\n")
first, err := ReduceOrderedConcat([]io.Reader{left, right})
if err != nil {
t.Fatal(err)
}
left, right = strings.NewReader("id,rows\nA,1\nB,2\n"), strings.NewReader("id,rows\nC,3\n")
second, err := ReduceOrderedConcat([]io.Reader{left, right})
if err != nil {
t.Fatal(err)
}
if string(first) != string(second) {
t.Fatalf("concat is not deterministic: %q != %q", first, second)
}
}
func TestReduceOrderedConcatRejectsInconsistentHeaders(t *testing.T) {
first := strings.NewReader("a,b\n1,2\n")
second := strings.NewReader("a,c\n1,2\n")
if _, err := ReduceOrderedConcat([]io.Reader{first, second}); err == nil {
t.Fatal("inconsistent headers must fail")
}
}
func TestReduceOrderedConcatRejectsRaggedRows(t *testing.T) {
partial := strings.NewReader("a,b\n1,2,3\n")
if _, err := ReduceOrderedConcat([]io.Reader{partial}); err == nil {
t.Fatal("ragged rows must fail")
}
}
func TestReduceOrderedConcatEmptyPartials(t *testing.T) {
output, err := ReduceOrderedConcat(nil)
if err != nil {
t.Fatal(err)
}
if len(output) != 0 {
t.Fatalf("empty input must produce empty output, got %q", output)
}
}
@@ -0,0 +1,195 @@
// Package reducer contains deterministic, coordinator-side result reductions.
package reducer
import (
"bytes"
"encoding/csv"
"errors"
"fmt"
"io"
"math"
"sort"
"strconv"
)
var searchHeader = []string{"rank", "chembl_id", "canonical_smiles", "similarity"}
type similarityMatch struct {
similarity float64
id string
smiles string
}
// ReduceSimilaritySearch streams worker-local top-k CSVs into the exact global
// top-k. Each partial is validated before it can affect the final artifact.
func ReduceSimilaritySearch(partials []io.Reader, parameters map[string]any) ([]byte, error) {
topK, err := positiveInt(parameters["top_k"], 20)
if err != nil {
return nil, err
}
direction, err := thresholdDirection(parameters["threshold_direction"])
if err != nil {
return nil, err
}
h := &matchHeap{direction: direction}
for _, partial := range partials {
if err := readPartial(partial, direction, func(match similarityMatch) {
if len(h.items) < topK {
heapPush(h, match)
return
}
if better(match, h.items[0], direction) {
h.items[0] = match
heapDown(h, 0)
}
}); err != nil {
return nil, err
}
}
matches := append([]similarityMatch(nil), h.items...)
sort.Slice(matches, func(i, j int) bool { return better(matches[i], matches[j], direction) })
var out bytes.Buffer
writer := csv.NewWriter(&out)
if err := writer.Write(searchHeader); err != nil {
return nil, err
}
for index, match := range matches {
if err := writer.Write([]string{
strconv.Itoa(index + 1), match.id, match.smiles, fmt.Sprintf("%.6f", match.similarity),
}); err != nil {
return nil, err
}
}
writer.Flush()
if err := writer.Error(); err != nil {
return nil, err
}
return out.Bytes(), nil
}
func readPartial(input io.Reader, direction string, consume func(similarityMatch)) error {
reader := csv.NewReader(input)
header, err := reader.Read()
if err != nil {
return fmt.Errorf("read partial header: %w", err)
}
if !equalStrings(header, searchHeader) {
return fmt.Errorf("partial result has an invalid CSV header")
}
var previous *similarityMatch
for rank := 1; ; rank++ {
row, err := reader.Read()
if errors.Is(err, io.EOF) {
return nil
}
if err != nil {
return fmt.Errorf("read partial row: %w", err)
}
if len(row) != len(searchHeader) || row[0] != strconv.Itoa(rank) {
return fmt.Errorf("partial result has an invalid rank")
}
score, err := strconv.ParseFloat(row[3], 64)
if err != nil || math.IsNaN(score) || math.IsInf(score, 0) || score < 0 || score > 1 {
return fmt.Errorf("partial result has an invalid similarity")
}
match := similarityMatch{similarity: score, id: row[1], smiles: row[2]}
if previous != nil && better(match, *previous, direction) {
return fmt.Errorf("partial result is not sorted deterministically")
}
previous = &match
consume(match)
}
}
func positiveInt(value any, fallback int) (int, error) {
if value == nil {
return fallback, nil
}
switch n := value.(type) {
case int:
if n > 0 {
return n, nil
}
case int64:
if n > 0 && n <= math.MaxInt {
return int(n), nil
}
case float64:
if n > 0 && n == math.Trunc(n) && n <= math.MaxInt {
return int(n), nil
}
}
return 0, fmt.Errorf("top_k must be a positive integer")
}
func thresholdDirection(value any) (string, error) {
if value == nil {
return "greater", nil
}
direction, ok := value.(string)
if !ok || (direction != "greater" && direction != "less") {
return "", fmt.Errorf("threshold_direction must be greater or less")
}
return direction, nil
}
func better(left, right similarityMatch, direction string) bool {
if left.similarity != right.similarity {
if direction == "less" {
return left.similarity < right.similarity
}
return left.similarity > right.similarity
}
if left.id != right.id {
return left.id < right.id
}
return left.smiles < right.smiles
}
func equalStrings(left, right []string) bool {
if len(left) != len(right) {
return false
}
for index := range left {
if left[index] != right[index] {
return false
}
}
return true
}
// matchHeap keeps the worst retained match at index zero.
type matchHeap struct {
items []similarityMatch
direction string
}
func heapPush(h *matchHeap, value similarityMatch) {
h.items = append(h.items, value)
for child := len(h.items) - 1; child > 0; {
parent := (child - 1) / 2
if !better(h.items[parent], h.items[child], h.direction) {
break
}
h.items[parent], h.items[child] = h.items[child], h.items[parent]
child = parent
}
}
func heapDown(h *matchHeap, parent int) {
for {
child := parent*2 + 1
if child >= len(h.items) {
return
}
if right := child + 1; right < len(h.items) && better(h.items[child], h.items[right], h.direction) {
child = right
}
if !better(h.items[parent], h.items[child], h.direction) {
return
}
h.items[parent], h.items[child] = h.items[child], h.items[parent]
parent = child
}
}
@@ -0,0 +1,41 @@
package reducer
import (
"io"
"strings"
"testing"
)
func TestReduceSimilaritySearchKeepsExactCrossShardRanking(t *testing.T) {
first := strings.NewReader("rank,chembl_id,canonical_smiles,similarity\n1,B,CCC,0.50000048\n2,C,CCCC,0.1\n")
second := strings.NewReader("rank,chembl_id,canonical_smiles,similarity\n1,A,CC,0.50000049\n")
output, err := ReduceSimilaritySearch([]io.Reader{first, second}, map[string]any{"top_k": 2})
if err != nil {
t.Fatal(err)
}
want := "rank,chembl_id,canonical_smiles,similarity\n1,A,CC,0.500000\n2,B,CCC,0.500000\n"
if string(output) != want {
t.Fatalf("output = %q, want %q", output, want)
}
}
func TestReduceSimilaritySearchSupportsLeastSimilarDirection(t *testing.T) {
partial := strings.NewReader("rank,chembl_id,canonical_smiles,similarity\n1,A,CC,0.1\n2,B,CCC,0.8\n")
output, err := ReduceSimilaritySearch([]io.Reader{partial}, map[string]any{
"top_k": 1, "threshold_direction": "less",
})
if err != nil {
t.Fatal(err)
}
if got, want := string(output), "rank,chembl_id,canonical_smiles,similarity\n1,A,CC,0.100000\n"; got != want {
t.Fatalf("output = %q, want %q", got, want)
}
}
func TestReduceSimilaritySearchRejectsMalformedPartial(t *testing.T) {
partial := strings.NewReader("rank,chembl_id,canonical_smiles,similarity\n2,A,CC,0.1\n")
if _, err := ReduceSimilaritySearch([]io.Reader{partial}, nil); err == nil {
t.Fatal("expected malformed rank error")
}
}
+239
View File
@@ -0,0 +1,239 @@
// Package setup implements the `coordinator setup` wizard: database reachability
// and creation, embedded schema migration, secret generation, and .env writing.
// The wizard never logs or echoes secrets.
package setup
import (
"bufio"
"context"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/emil28092005/SciMesh/coordinator/internal/storage/postgres"
)
// Options configures one wizard run.
type Options struct {
// DatabaseURL is the target coordinator database (pgx/libpq URL).
DatabaseURL string
// AdminDatabaseURL, when set, is used to create a missing target database.
// Defaults to the target URL with the database name replaced by "postgres".
AdminDatabaseURL string
// EnvFile is where the generated settings are written (default ".env").
EnvFile string
// Force overwrites an existing EnvFile.
Force bool
// Yes disables interactive prompts; missing values fail instead.
Yes bool
// ConnectTimeout bounds the reachability check.
ConnectTimeout time.Duration
// Out receives progress and summary output; In feeds interactive answers.
Out io.Writer
In io.Reader
}
// Run executes the wizard and returns a summary of what was done.
func Run(ctx context.Context, options Options) (string, error) {
if options.DatabaseURL == "" {
return "", fmt.Errorf("DATABASE_URL is required (or pass --db)")
}
if options.EnvFile == "" {
options.EnvFile = ".env"
}
if options.ConnectTimeout <= 0 {
options.ConnectTimeout = 5 * time.Second
}
if options.Out == nil {
options.Out = os.Stdout
}
report := func(format string, args ...any) {
_, _ = fmt.Fprintf(options.Out, format+"\n", args...)
}
report("SciMesh coordinator setup")
report("")
// 1. Reachability, with optional database creation.
target, err := pgx.ParseConfig(options.DatabaseURL)
if err != nil {
return "", fmt.Errorf("DATABASE_URL is not a valid postgres URL: %w", err)
}
if err := probeDatabase(ctx, target, options.ConnectTimeout); err != nil {
var pgErr *pgconn.PgError
if !errors.As(err, &pgErr) || pgErr.Code != "3D000" {
return "", fmt.Errorf("cannot reach the coordinator database: %w", err)
}
report("database %q does not exist yet", target.Database)
admin, err := resolveAdminConfig(options, target)
if err != nil {
return "", err
}
if err := createDatabase(ctx, admin, target.Database, options.ConnectTimeout); err != nil {
return "", fmt.Errorf("cannot create database %q: %w", target.Database, err)
}
report("created database %q", target.Database)
}
report("database %q is reachable", target.Database)
// 2. Apply the embedded schema migrations (idempotent).
if err := postgres.Migrate(ctx, options.DatabaseURL, nil); err != nil {
return "", fmt.Errorf("apply schema migrations: %w", err)
}
report("schema migrations applied")
// 3. JWT secret: reuse the environment value when strong, else generate.
secret := os.Getenv("JWT_SECRET")
if secret != "" && len(secret) < 32 {
return "", fmt.Errorf("JWT_SECRET must be at least 32 bytes")
}
if secret == "" {
generated, err := generateSecret()
if err != nil {
return "", fmt.Errorf("generate JWT_SECRET: %w", err)
}
secret = generated
report("generated a fresh JWT_SECRET")
}
// 4. Write the .env file.
storageDir := os.Getenv("COORDINATOR_STORAGE_DIR")
if storageDir == "" {
storageDir = "./data"
}
if err := writeEnvFile(options, secret, storageDir); err != nil {
return "", err
}
// 5. Summary.
var summary strings.Builder
fmt.Fprintf(&summary, "Setup complete.\n\n")
fmt.Fprintf(&summary, "Ready:\n")
fmt.Fprintf(&summary, " - database %s is reachable and migrated\n", target.Database)
fmt.Fprintf(&summary, " - settings written to %s (chmod 0600)\n", options.EnvFile)
fmt.Fprintf(&summary, "\nStart the coordinator:\n")
fmt.Fprintf(&summary, " ENV_FILE=%s ./coordinator\n", options.EnvFile)
fmt.Fprintf(&summary, "\nOptional — userservice for UI logins (must share JWT_SECRET):\n")
fmt.Fprintf(&summary, " cd users && JWT_SECRET=%q docker compose up -d\n", secret)
fmt.Fprintf(&summary, " then set USERSERVICE_URL=http://localhost:8081 and BOOTSTRAP_ADMIN_EMAIL/PASSWORD\n")
fmt.Fprintf(&summary, "\nThe wizard cannot run PostgreSQL or the userservice for you; the\n")
fmt.Fprintf(&summary, "commands above are the supported way to start them.\n")
return summary.String(), nil
}
// probeDatabase verifies the target database accepts connections.
func probeDatabase(ctx context.Context, config *pgx.ConnConfig, timeout time.Duration) error {
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
conn, err := pgx.ConnectConfig(ctx, config)
if err != nil {
return err
}
return conn.Close(ctx)
}
// resolveAdminConfig picks the maintenance connection used to create
// databases. pgx's ConnConfig.ConnString() caches the original URL, so the
// config itself (not a re-rendered string) is what the caller connects with.
func resolveAdminConfig(options Options, target *pgx.ConnConfig) (*pgx.ConnConfig, error) {
if options.AdminDatabaseURL != "" {
config, err := pgx.ParseConfig(options.AdminDatabaseURL)
if err != nil {
return nil, fmt.Errorf("--admin-db is not a valid postgres URL: %w", err)
}
return config, nil
}
admin := *target
admin.Database = "postgres"
return &admin, nil
}
// createDatabase creates the named database through the maintenance connection.
func createDatabase(ctx context.Context, admin *pgx.ConnConfig, name string, timeout time.Duration) error {
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
conn, err := pgx.ConnectConfig(ctx, admin)
if err != nil {
return err
}
defer func() { _ = conn.Close(ctx) }()
quoted := `"` + strings.ReplaceAll(name, `"`, `""`) + `"`
if _, err := conn.Exec(ctx, "CREATE DATABASE "+quoted); err != nil {
return err
}
return nil
}
// generateSecret returns 32 random bytes as lowercase hex.
func generateSecret() (string, error) {
buffer := make([]byte, 32)
if _, err := rand.Read(buffer); err != nil {
return "", err
}
return hex.EncodeToString(buffer), nil
}
// writeEnvFile writes the settings, refusing to clobber without --force.
func writeEnvFile(options Options, secret, storageDir string) error {
path := filepath.Clean(options.EnvFile)
if _, err := os.Stat(path); err == nil && !options.Force {
return fmt.Errorf("%s already exists (use --force to overwrite)", path)
}
content := strings.Join([]string{
"DATABASE_URL=" + options.DatabaseURL,
"JWT_SECRET=" + secret,
"COORDINATOR_STORAGE_DIR=" + storageDir,
"", // trailing newline
}, "\n")
// #nosec G703 -- the env file path is operator-supplied (--env-file / ENV_FILE).
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
return fmt.Errorf("write %s: %w", path, err)
}
if err := os.Chmod(path, 0o600); err != nil {
return fmt.Errorf("chmod %s: %w", path, err)
}
return nil
}
// SanitizeDatabaseURL hides the password for logging.
func SanitizeDatabaseURL(raw string) string {
at := strings.LastIndex(raw, "@")
if at < 0 {
return raw
}
start := 0
if strings.HasPrefix(raw, "postgres://") || strings.HasPrefix(raw, "postgresql://") {
start = len("postgres://")
}
colon := strings.Index(raw[start:at], ":")
if colon < 0 {
return raw
}
colon += start
return raw[:colon] + ":***@" + raw[at+1:]
}
// prompt asks a question and returns the trimmed answer ("" on EOF).
func prompt(options Options, question, fallback string) string {
_, _ = fmt.Fprintf(options.Out, "%s [%s]: ", question, fallback)
reader := bufio.NewReader(options.In)
line, err := reader.ReadString('\n')
if err != nil && !errors.Is(err, io.EOF) {
return fallback
}
answer := strings.TrimSpace(line)
if answer == "" {
return fallback
}
return answer
}
@@ -0,0 +1,91 @@
//go:build integration
package setup
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/jackc/pgx/v5"
)
func TestRunProvisionsDatabaseSchemaAndEnvFile(t *testing.T) {
ctx := context.Background()
base := os.Getenv("TEST_DATABASE_URL")
if base == "" {
t.Skip("TEST_DATABASE_URL is not set")
}
// The wizard must create a *missing* database through the admin URL.
slash := strings.LastIndex(base, "/")
target := base[:slash+1] + "scimesh_setup_test"
admin := base[:slash+1] + "postgres"
cleanup := func() {
conn, err := pgx.Connect(ctx, admin)
if err != nil {
return
}
defer func() { _ = conn.Close(ctx) }()
_, _ = conn.Exec(ctx, `DROP DATABASE IF EXISTS "scimesh_setup_test"`)
}
cleanup()
t.Cleanup(cleanup)
envPath := filepath.Join(t.TempDir(), ".env")
var output strings.Builder
options := Options{
DatabaseURL: target,
AdminDatabaseURL: admin,
EnvFile: envPath,
Force: true,
Yes: true,
ConnectTimeout: 10 * time.Second,
Out: &output,
In: strings.NewReader(""),
}
summary, err := Run(ctx, options)
if err != nil {
t.Fatalf("setup run: %v", err)
}
for _, expected := range []string{"created database", "schema migrations applied"} {
if !strings.Contains(output.String(), expected) {
t.Errorf("progress output is missing %q:\n%s", expected, output.String())
}
}
for _, expected := range []string{"Setup complete", "Start the coordinator"} {
if !strings.Contains(summary, expected) {
t.Errorf("summary is missing %q:\n%s", expected, summary)
}
}
// The database now exists, is migrated, and the env file is written.
conn, err := pgx.Connect(ctx, target)
if err != nil {
t.Fatalf("connect to provisioned database: %v", err)
}
defer func() { _ = conn.Close(ctx) }()
var watermark int64
if err := conn.QueryRow(ctx, "SELECT COALESCE(MAX(version), 0) FROM schema_migrations").Scan(&watermark); err != nil {
t.Fatalf("read schema_migrations: %v", err)
}
if watermark < 13 {
t.Errorf("schema watermark = %d, want >= 13", watermark)
}
envContent, err := os.ReadFile(envPath)
if err != nil {
t.Fatal(err)
}
env := string(envContent)
if !strings.Contains(env, "DATABASE_URL="+target) || !strings.Contains(env, "JWT_SECRET=") {
t.Errorf("env file is incomplete:\n%s", env)
}
// A second run is idempotent: no create-database error, same outcome.
if _, err := Run(ctx, options); err != nil {
t.Fatalf("second setup run: %v", err)
}
}
+96
View File
@@ -0,0 +1,96 @@
package setup
import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
)
func TestGenerateSecretIsRandomAndStrong(t *testing.T) {
first, err := generateSecret()
if err != nil {
t.Fatal(err)
}
second, err := generateSecret()
if err != nil {
t.Fatal(err)
}
if len(first) != 64 || len(second) != 64 {
t.Fatalf("secrets must be 32 random bytes as hex, got %d and %d", len(first), len(second))
}
if first == second {
t.Fatal("two generated secrets must differ")
}
}
func TestWriteEnvFileContentsAndPermissions(t *testing.T) {
path := filepath.Join(t.TempDir(), ".env")
if err := writeEnvFile(Options{
EnvFile: path,
DatabaseURL: "postgres://scimesh@localhost/scimesh",
}, "s3cr3t", "./data"); err != nil {
t.Fatal(err)
}
content, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
want := "DATABASE_URL=postgres://scimesh@localhost/scimesh\nJWT_SECRET=s3cr3t\nCOORDINATOR_STORAGE_DIR=./data\n"
if got := string(content); got != want {
t.Fatalf("env file = %q, want %q", got, want)
}
info, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
if info.Mode().Perm() != 0o600 {
t.Errorf("env file mode = %o, want 0600", info.Mode().Perm())
}
}
func TestWriteEnvFileRefusesWithoutForce(t *testing.T) {
path := filepath.Join(t.TempDir(), ".env")
if err := writeEnvFile(Options{EnvFile: path, DatabaseURL: "postgres://x@localhost/a"}, "a", "./data"); err != nil {
t.Fatal(err)
}
if err := writeEnvFile(Options{EnvFile: path, DatabaseURL: "postgres://x@localhost/a"}, "b", "./data"); err == nil {
t.Fatal("second write without --force must fail")
}
if err := writeEnvFile(Options{EnvFile: path, Force: true, DatabaseURL: "postgres://x@localhost/a"}, "b", "./data"); err != nil {
t.Fatalf("write with --force: %v", err)
}
}
func TestPromptReadsAnswer(t *testing.T) {
var out bytes.Buffer
answer := prompt(Options{Out: &out, In: strings.NewReader("postgres://custom\n")}, "Database URL", "default")
if answer != "postgres://custom" {
t.Fatalf("answer = %q, want the typed value", answer)
}
if !strings.Contains(out.String(), "Database URL [default]:") {
t.Fatalf("prompt output = %q", out.String())
}
fallback := prompt(Options{Out: &out, In: strings.NewReader("\n")}, "Question", "fb")
if fallback != "fb" {
t.Fatalf("empty answer must fall back, got %q", fallback)
}
}
func TestSanitizeDatabaseURL(t *testing.T) {
cases := map[string]string{
"postgres://scimesh:hunter2@localhost:5432/scimesh?sslmode=disable": "postgres://scimesh:***@localhost:5432/scimesh?sslmode=disable",
"postgresql://scimesh@localhost/scimesh": "postgresql://scimesh@localhost/scimesh",
"not-a-url": "not-a-url",
}
for raw, want := range cases {
got := SanitizeDatabaseURL(raw)
if got != want {
t.Errorf("sanitize(%q) = %q, want %q", raw, got, want)
}
if strings.Contains(got, "hunter2") {
t.Errorf("sanitize(%q) leaked the password", raw)
}
}
}
+130
View File
@@ -0,0 +1,130 @@
// Package blob stores artifact bytes on the local filesystem. It implements
// usecase.BlobStore; no other layer knows where or how the bytes are kept.
package blob
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"github.com/emil28092005/SciMesh/coordinator/internal/usecase"
)
// FSStore keeps each artifact as one file under dir, named by its storage key.
type FSStore struct {
dir string
staging string
}
var _ usecase.BlobStore = (*FSStore)(nil)
// NewFSStore prepares the storage and staging directories. Staging lives inside
// dir so a finished file can be renamed into place on the same filesystem —
// rename is only atomic within one filesystem.
func NewFSStore(dir string) (*FSStore, error) {
staging := filepath.Join(dir, ".staging")
if err := os.MkdirAll(staging, 0o750); err != nil {
return nil, fmt.Errorf("create blob dirs: %w", err)
}
return &FSStore{dir: dir, staging: staging}, nil
}
// Put streams r to a staging file while hashing it, then atomically renames it
// into place. A caller that dies mid-upload leaves at most a staging temp file,
// never a half-written artifact that looks complete.
func (s *FSStore) Put(ctx context.Context, key string, r io.Reader) (string, int64, error) {
if err := checkKey(key); err != nil {
return "", 0, err
}
tmp, err := os.CreateTemp(s.staging, key+"-*")
if err != nil {
return "", 0, fmt.Errorf("create staging file: %w", err)
}
tmpName := tmp.Name()
// On any failure past this point, do not leave the temp file behind.
defer func() {
if tmpName != "" {
_ = os.Remove(tmpName)
}
}()
h := sha256.New()
// Tee the stream: one copy to disk, one to the hasher, in a single pass so
// the bytes are never held in memory or read twice.
size, err := io.Copy(io.MultiWriter(tmp, h), &ctxReader{ctx: ctx, r: r})
if err != nil {
_ = tmp.Close()
return "", 0, fmt.Errorf("write artifact: %w", err)
}
// fsync before rename so a crash cannot leave a renamed-but-empty file.
if err := tmp.Sync(); err != nil {
_ = tmp.Close()
return "", 0, fmt.Errorf("sync artifact: %w", err)
}
if err := tmp.Close(); err != nil {
return "", 0, fmt.Errorf("close artifact: %w", err)
}
final := filepath.Join(s.dir, key)
if err := os.Rename(tmpName, final); err != nil {
return "", 0, fmt.Errorf("commit artifact: %w", err)
}
tmpName = "" // committed — the deferred cleanup must not delete it now
return hex.EncodeToString(h.Sum(nil)), size, nil
}
// Open returns the artifact bytes for streaming to a client. The caller closes.
func (s *FSStore) Open(ctx context.Context, key string) (io.ReadCloser, error) {
if err := checkKey(key); err != nil {
return nil, err
}
// checkKey has rejected any traversal, so the joined path stays under s.dir.
f, err := os.Open(filepath.Join(s.dir, key)) //nolint:gosec // key validated by checkKey
if err != nil {
return nil, err
}
return f, nil
}
// Delete removes a stored blob. Absence is not an error: cleaning up after a
// failed metadata insert must be idempotent.
func (s *FSStore) Delete(ctx context.Context, key string) error {
if err := checkKey(key); err != nil {
return err
}
if err := os.Remove(filepath.Join(s.dir, key)); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
// checkKey rejects anything that could escape the storage directory. Keys are
// coordinator-generated UUIDs, so this is defence in depth, not the only guard.
func checkKey(key string) error {
if key == "" || strings.ContainsAny(key, `/\`) || strings.Contains(key, "..") {
return fmt.Errorf("invalid storage key %q", key)
}
return nil
}
// ctxReader aborts a copy when the request context is cancelled, so a stalled
// or disconnected upload does not tie up a file handle indefinitely.
type ctxReader struct {
ctx context.Context
r io.Reader
}
func (c *ctxReader) Read(p []byte) (int, error) {
if err := c.ctx.Err(); err != nil {
return 0, err
}
return c.r.Read(p)
}
@@ -0,0 +1,115 @@
package blob
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"io"
"os"
"path/filepath"
"strings"
"testing"
)
func newStore(t *testing.T) *FSStore {
t.Helper()
s, err := NewFSStore(t.TempDir())
if err != nil {
t.Fatalf("NewFSStore: %v", err)
}
return s
}
func TestPutComputesChecksumAndSize(t *testing.T) {
s := newStore(t)
data := bytes.Repeat([]byte("chembl-row\n"), 10000) // ~110 KB, streamed
sum, size, err := s.Put(context.Background(), "key-1", bytes.NewReader(data))
if err != nil {
t.Fatalf("Put: %v", err)
}
want := sha256.Sum256(data)
if sum != hex.EncodeToString(want[:]) {
t.Errorf("sha256 = %s, want %s", sum, hex.EncodeToString(want[:]))
}
if size != int64(len(data)) {
t.Errorf("size = %d, want %d", size, len(data))
}
}
func TestPutThenOpenRoundTrips(t *testing.T) {
s := newStore(t)
data := []byte("partial result csv\n1,2,3\n")
if _, _, err := s.Put(context.Background(), "key-2", bytes.NewReader(data)); err != nil {
t.Fatalf("Put: %v", err)
}
rc, err := s.Open(context.Background(), "key-2")
if err != nil {
t.Fatalf("Open: %v", err)
}
defer rc.Close()
got, _ := io.ReadAll(rc)
if !bytes.Equal(got, data) {
t.Errorf("round-trip mismatch: got %q", got)
}
}
func TestPutLeavesNoStagingFileBehind(t *testing.T) {
s := newStore(t)
if _, _, err := s.Put(context.Background(), "key-3", strings.NewReader("x")); err != nil {
t.Fatalf("Put: %v", err)
}
entries, _ := os.ReadDir(s.staging)
if len(entries) != 0 {
t.Errorf("staging dir not empty after a successful put: %v", entries)
}
}
func TestPutFailureLeavesNoArtifactOrStaging(t *testing.T) {
s := newStore(t)
// A reader that errors partway through simulates a dropped upload.
r := io.MultiReader(strings.NewReader("half"), &erroringReader{})
if _, _, err := s.Put(context.Background(), "key-4", r); err == nil {
t.Fatal("expected an error from a failing reader")
}
if _, err := os.Stat(filepath.Join(s.dir, "key-4")); !os.IsNotExist(err) {
t.Error("a failed put must not leave a committed artifact")
}
if entries, _ := os.ReadDir(s.staging); len(entries) != 0 {
t.Errorf("a failed put must not leave staging files: %v", entries)
}
}
func TestPutRejectsUnsafeKeys(t *testing.T) {
s := newStore(t)
for _, key := range []string{"", "../escape", "a/b", `a\b`, "with..dots"} {
if _, _, err := s.Put(context.Background(), key, strings.NewReader("x")); err == nil {
t.Errorf("key %q should have been rejected", key)
}
}
}
func TestPutHonoursContextCancellation(t *testing.T) {
s := newStore(t)
ctx, cancel := context.WithCancel(context.Background())
cancel() // already cancelled before the copy starts
if _, _, err := s.Put(ctx, "key-5", strings.NewReader("data")); err == nil {
t.Fatal("expected cancellation to abort the put")
}
if _, err := os.Stat(filepath.Join(s.dir, "key-5")); !os.IsNotExist(err) {
t.Error("a cancelled put must not leave an artifact")
}
}
type erroringReader struct{}
func (*erroringReader) Read([]byte) (int, error) { return 0, io.ErrUnexpectedEOF }
@@ -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
}
@@ -0,0 +1,102 @@
package postgres
import (
"context"
"errors"
"fmt"
sq "github.com/Masterminds/squirrel"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/emil28092005/SciMesh/coordinator/internal/domain"
"github.com/emil28092005/SciMesh/coordinator/internal/usecase"
)
// ArtifactRepo implements usecase.ArtifactRepository.
type ArtifactRepo struct {
pool *pgxpool.Pool
}
func NewArtifactRepo(pool *pgxpool.Pool) *ArtifactRepo {
return &ArtifactRepo{pool: pool}
}
var _ usecase.ArtifactRepository = (*ArtifactRepo)(nil)
var artifactColumns = []string{
"id", "job_id", "task_id", "attempt", "kind", "filename", "storage_key",
"content_type", "size_bytes", "sha256", "created_at",
}
func (r *ArtifactRepo) Insert(ctx context.Context, a *domain.Artifact) error {
sql, args, err := psql.Insert("artifacts").
Columns(artifactColumns...).
Values(a.ID, a.JobID, a.TaskID, a.Attempt, string(a.Kind), a.Filename, a.StorageKey,
a.ContentType, a.SizeBytes, a.SHA256, a.CreatedAt).
ToSql()
if err != nil {
return err
}
if _, err := conn(ctx, r.pool).Exec(ctx, sql, args...); err != nil {
return fmt.Errorf("insert artifact: %w", err)
}
return nil
}
func (r *ArtifactRepo) Get(ctx context.Context, id uuid.UUID) (*domain.Artifact, error) {
sql, args, err := psql.Select(artifactColumns...).
From("artifacts").
Where(sq.Eq{"id": id}).
ToSql()
if err != nil {
return nil, err
}
var (
a domain.Artifact
kind string
)
err = conn(ctx, r.pool).QueryRow(ctx, sql, args...).Scan(
&a.ID, &a.JobID, &a.TaskID, &a.Attempt, &kind, &a.Filename, &a.StorageKey,
&a.ContentType, &a.SizeBytes, &a.SHA256, &a.CreatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, domain.ErrArtifactNotFound
}
if err != nil {
return nil, fmt.Errorf("get artifact: %w", err)
}
a.Kind = domain.ArtifactKind(kind)
return &a, nil
}
func (r *ArtifactRepo) FindPartialResult(ctx context.Context, taskID uuid.UUID, attempt int) (*domain.Artifact, error) {
sql, args, err := psql.Select(artifactColumns...).
From("artifacts").
Where(sq.Eq{
"task_id": taskID,
"attempt": attempt,
"kind": string(domain.ArtifactPartialResult),
}).
ToSql()
if err != nil {
return nil, err
}
var (
a domain.Artifact
kind string
)
err = conn(ctx, r.pool).QueryRow(ctx, sql, args...).Scan(
&a.ID, &a.JobID, &a.TaskID, &a.Attempt, &kind, &a.Filename, &a.StorageKey,
&a.ContentType, &a.SizeBytes, &a.SHA256, &a.CreatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("find partial result: %w", err)
}
a.Kind = domain.ArtifactKind(kind)
return &a, nil
}
@@ -0,0 +1,11 @@
package postgres
import sq "github.com/Masterminds/squirrel"
// psql is the shared statement builder, fixed to PostgreSQL $N placeholders so
// no call site repeats PlaceholderFormat(sq.Dollar).
//
// Not everything goes through it. Two genuinely set-based statements stay as
// raw SQL — claimNext (a FOR UPDATE SKIP LOCKED CTE) and expireLeases (CASE
// logic in the SET) — because a builder would obscure them, not clarify them.
var psql = sq.StatementBuilder.PlaceholderFormat(sq.Dollar)
@@ -0,0 +1,703 @@
//go:build integration
// Integration tests run against a real PostgreSQL instance supplied through
// TEST_DATABASE_URL. The spec forbids mocks or SQLite here: the guarantees
// being verified — FOR UPDATE SKIP LOCKED, optimistic concurrency, transaction
// rollback — are properties of Postgres, not of our Go code.
//
// docker compose up -d
// TEST_DATABASE_URL='postgres://scimesh:scimesh@localhost:5432/scimesh?sslmode=disable' \
// go test -tags=integration ./internal/storage/postgres/ -v
package postgres
import (
"context"
"errors"
"fmt"
"os"
"sync"
"testing"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/emil28092005/SciMesh/coordinator/internal/domain"
"github.com/emil28092005/SciMesh/coordinator/internal/usecase"
"github.com/emil28092005/SciMesh/coordinator/internal/workloads"
)
func testPool(t *testing.T) *pgxpool.Pool {
t.Helper()
url := os.Getenv("TEST_DATABASE_URL")
if url == "" {
t.Skip("TEST_DATABASE_URL is not set")
}
pool, err := pgxpool.New(context.Background(), url)
if err != nil {
t.Fatalf("connect: %v", err)
}
t.Cleanup(pool.Close)
return pool
}
// seedJob creates a job with n pending tasks and removes them afterwards, so
// tests stay independent of each other and of leftovers from earlier runs.
func seedJob(t *testing.T, pool *pgxpool.Pool, n int) (*domain.Job, []*domain.Task) {
t.Helper()
ctx := context.Background()
chunks := make([]domain.ChunkSpec, 0, n)
for i := 0; i < n; i++ {
chunks = append(chunks, domain.ChunkSpec{
ChunkIndex: i,
InputURI: fmt.Sprintf("s3://chunk-%d", i),
InputSHA256: fmt.Sprintf("sha-%d", i),
})
}
job, tasks, err := domain.NewJobWithTasks("similarity_search", "s3://ds", nil, chunks, time.Now().UTC())
if err != nil {
t.Fatalf("build job: %v", err)
}
jobs, taskRepo, tx := NewJobRepo(pool), NewTaskRepo(pool), NewTxManager(pool)
err = tx.WithinTx(ctx, func(ctx context.Context) error {
if err := jobs.Insert(ctx, job); err != nil {
return err
}
return taskRepo.InsertBatch(ctx, tasks)
})
if err != nil {
t.Fatalf("seed: %v", err)
}
t.Cleanup(func() {
// ON DELETE CASCADE removes the tasks with it.
_, _ = pool.Exec(context.Background(), `DELETE FROM jobs WHERE id = $1`, job.ID)
})
return job, tasks
}
func TestCreateJobPersistsEveryTask(t *testing.T) {
pool := testPool(t)
job, _ := seedJob(t, pool, 3)
counts, err := NewTaskRepo(pool).CountByStatus(context.Background(), job.ID)
if err != nil {
t.Fatalf("count: %v", err)
}
if counts[domain.TaskPending] != 3 {
t.Errorf("pending = %d, want 3", counts[domain.TaskPending])
}
}
func TestClaimReductionIsAtomic(t *testing.T) {
pool := testPool(t)
job, _ := seedJob(t, pool, 1)
repo := NewJobRepo(pool)
ctx := context.Background()
if err := repo.UpdateStatus(ctx, job.ID, domain.JobReducing, nil); err != nil {
t.Fatal(err)
}
var (
wg sync.WaitGroup
mu sync.Mutex
claimed int
)
for range 8 {
wg.Add(1)
go func() {
defer wg.Done()
ok, err := repo.ClaimReduction(context.Background(), job.ID, time.Now().UTC())
if err != nil {
t.Errorf("claim reduction: %v", err)
return
}
if ok {
mu.Lock()
claimed++
mu.Unlock()
}
}()
}
wg.Wait()
if claimed != 1 {
t.Fatalf("reducer claims = %d, want 1", claimed)
}
stored, err := repo.Get(ctx, job.ID)
if err != nil {
t.Fatal(err)
}
if stored.Status != domain.JobReducing || stored.ReducerStartedAt == nil {
t.Fatalf("stored reduction state = %+v", stored)
}
}
func TestUIReadRepoListsReducerFields(t *testing.T) {
pool := testPool(t)
job, _ := seedJob(t, pool, 1)
jobs := NewJobRepo(pool)
ctx := context.Background()
if err := jobs.UpdateStatus(ctx, job.ID, domain.JobReducing, nil); err != nil {
t.Fatal(err)
}
if claimed, err := jobs.ClaimReduction(ctx, job.ID, time.Now().UTC()); err != nil || !claimed {
t.Fatalf("claim reduction = (%v, %v)", claimed, err)
}
listed, _, err := NewAdminReadRepo(pool).ListJobsPaginated(ctx, "", 20, 0)
if err != nil {
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("admin reducer projection = %+v", item)
}
return
}
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
// worker could ever complete.
func TestCreateJobRollsBackOnFailure(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
chunks := []domain.ChunkSpec{{ChunkIndex: 0, InputURI: "s3://c0", InputSHA256: "sha0"}}
job, tasks, err := domain.NewJobWithTasks("similarity_search", "s3://ds", nil, chunks, time.Now().UTC())
if err != nil {
t.Fatal(err)
}
jobs, taskRepo, tx := NewJobRepo(pool), NewTaskRepo(pool), NewTxManager(pool)
boom := errors.New("boom")
err = tx.WithinTx(ctx, func(ctx context.Context) error {
if err := jobs.Insert(ctx, job); err != nil {
return err
}
if err := taskRepo.InsertBatch(ctx, tasks); err != nil {
return err
}
return boom // fail after both writes
})
if !errors.Is(err, boom) {
t.Fatalf("err = %v, want boom", err)
}
if _, err := jobs.Get(ctx, job.ID); !errors.Is(err, domain.ErrJobNotFound) {
t.Errorf("job survived the rollback: %v", err)
}
}
// The acceptance criterion: N workers claiming at once must each get a
// different task, and no task may be handed out twice.
func TestConcurrentClaimGivesEachTaskToExactlyOneWorker(t *testing.T) {
pool := testPool(t)
const tasks = 8
job, _ := seedJob(t, pool, tasks)
repo := NewTaskRepo(pool)
now := time.Now().UTC()
var (
mu sync.Mutex
claimed = make(map[uuid.UUID]string)
wg sync.WaitGroup
)
// More workers than tasks. With SKIP LOCKED, a concurrent caller can
// transiently see no eligible row while every remaining row is locked by a
// different claim statement. Poll briefly, as a real worker does, before
// treating the queue as empty. This verifies the actual contract: tasks are
// unique and all eventually become claimable without lock contention.
for i := 0; i < tasks*2; i++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
for attempt := 0; attempt < 20; attempt++ {
task, err := repo.ClaimNext(context.Background(), usecase.ClaimFilter{
Owner: fmt.Sprintf("worker-%d", n),
Now: now,
LeaseUntil: now.Add(time.Minute),
})
if err != nil {
t.Errorf("claim: %v", err)
return
}
if task == nil || task.JobID != job.ID {
time.Sleep(time.Millisecond)
continue
}
mu.Lock()
if prev, dup := claimed[task.ID]; dup {
t.Errorf("task %s handed to both %s and worker-%d", task.ID, prev, n)
}
claimed[task.ID] = fmt.Sprintf("worker-%d", n)
mu.Unlock()
return
}
}(i)
}
wg.Wait()
if len(claimed) != tasks {
t.Errorf("claimed %d tasks, want %d", len(claimed), tasks)
}
}
func TestClaimNextReturnsNilOnEmptyQueue(t *testing.T) {
pool := testPool(t)
now := time.Now().UTC()
// Drain everything first, then ask once more.
repo := NewTaskRepo(pool)
for {
task, err := repo.ClaimNext(context.Background(), usecase.ClaimFilter{
Owner: "drainer", Now: now, LeaseUntil: now.Add(time.Minute),
})
if err != nil {
t.Fatalf("drain: %v", err)
}
if task == nil {
break
}
}
task, err := repo.ClaimNext(context.Background(), usecase.ClaimFilter{
Owner: "worker-1", Now: now, LeaseUntil: now.Add(time.Minute),
})
if err != nil {
t.Fatalf("claim: %v", err)
}
if task != nil {
t.Errorf("expected nil on an empty queue, got %s", task.ID)
}
}
func TestCancelJobCancelsEveryUnfinishedTask(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
job, _ := seedJob(t, pool, 3)
clk := fixedClock{now: time.Now().UTC()}
uc := usecase.NewCancelJob(NewJobRepo(pool), NewTaskRepo(pool), NewTxManager(pool), clk)
cancelled, err := uc.Execute(ctx, job.ID)
if err != nil || cancelled != 3 {
t.Fatalf("cancel = (%d, %v), want (3, nil)", cancelled, err)
}
stored, err := NewJobRepo(pool).Get(ctx, job.ID)
if err != nil || stored.Status != domain.JobCancelled {
t.Fatalf("job after cancel = (%+v, %v)", stored, err)
}
counts, err := NewTaskRepo(pool).CountByStatus(ctx, job.ID)
if err != nil || counts[domain.TaskCancelled] != 3 {
t.Fatalf("cancelled tasks = %d, err = %v", counts[domain.TaskCancelled], err)
}
}
func TestUpdateRejectsStaleVersion(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
job, _ := seedJob(t, pool, 1)
repo, tx := NewTaskRepo(pool), NewTxManager(pool)
now := time.Now().UTC()
task, err := repo.ClaimNext(ctx, usecase.ClaimFilter{
Owner: "worker-1", Now: now, LeaseUntil: now.Add(time.Minute),
})
if err != nil || task == nil || task.JobID != job.ID {
t.Skipf("could not claim this job's task (got %v, %v)", task, err)
}
// A stale copy: same row, but the version it remembers is behind.
stale := *task
stale.Version = task.Version // pretend the caller mutated it once
err = tx.WithinTx(ctx, func(ctx context.Context) error {
fresh, err := repo.GetForUpdate(ctx, task.ID)
if err != nil {
return err
}
if err := fresh.RenewLease("worker-1", fresh.Attempt, now, now.Add(2*time.Minute)); err != nil {
return err
}
return repo.Update(ctx, fresh)
})
if err != nil {
t.Fatalf("legitimate update failed: %v", err)
}
// Now the stale copy's version is behind by one; its write must be refused.
stale.Version++ // as a domain method would have done
if err := repo.Update(ctx, &stale); !errors.Is(err, domain.ErrLeaseConflict) {
t.Errorf("stale update err = %v, want ErrLeaseConflict", err)
}
}
func TestListCompletedIsOrderedByChunkIndex(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
job, tasks := seedJob(t, pool, 4)
repo, artifacts, tx := NewTaskRepo(pool), NewArtifactRepo(pool), NewTxManager(pool)
now := time.Now().UTC()
// Complete them out of order to prove the ordering comes from SQL.
for _, i := range []int{2, 0, 3, 1} {
task := tasks[i]
err := tx.WithinTx(ctx, func(ctx context.Context) error {
// A completed task must reference a real result artifact (FK + check).
taskID := task.ID
art, err := domain.NewArtifact(job.ID, &taskID, domain.ArtifactPartialResult,
fmt.Sprintf("result-%d.csv", task.ChunkIndex), "text/csv", now)
if err != nil {
return err
}
art.SetContent(fmt.Sprintf("rsha-%d", task.ChunkIndex), 1)
attempt := 1
art.Attempt = &attempt
if err := artifacts.Insert(ctx, art); err != nil {
return err
}
fresh, err := repo.GetForUpdate(ctx, task.ID)
if err != nil {
return err
}
owner := "worker-1"
fresh.Status = domain.TaskLeased
fresh.Attempt = attempt
fresh.LeaseOwner = &owner
expires := now.Add(time.Minute)
fresh.LeaseExpiresAt = &expires
if err := fresh.CompleteWith(art.ID, nil, owner, fresh.Attempt, now); err != nil {
return err
}
return repo.Update(ctx, fresh)
})
if err != nil {
t.Fatalf("complete chunk %d: %v", i, err)
}
}
done, err := repo.ListCompleted(ctx, job.ID)
if err != nil {
t.Fatalf("list: %v", err)
}
if len(done) != 4 {
t.Fatalf("got %d completed, want 4", len(done))
}
for i, task := range done {
if task.ChunkIndex != i {
t.Errorf("position %d holds chunk_index %d — order is not deterministic", i, task.ChunkIndex)
}
}
}
// A worker whose network dropped resends the same manifest. That must succeed:
// the entity is unchanged, so nothing is written, and the optimistic-concurrency
// guard must not turn the replay into a conflict.
func TestCompleteTaskReplayIsIdempotent(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
job, _ := seedJob(t, pool, 1)
tasks, jobs, artifacts, tx := NewTaskRepo(pool), NewJobRepo(pool), NewArtifactRepo(pool), NewTxManager(pool)
workers, results := NewWorkerRepo(pool), NewTaskResultRepo(pool)
clk := fixedClock{now: time.Now().UTC()}
uc := usecase.NewCompleteTask(tasks, jobs, artifacts, workers, results, tx, clk, 2, integrationCatalog())
claimed, err := tasks.ClaimNext(ctx, usecase.ClaimFilter{
Owner: "worker-1", Now: clk.now, LeaseUntil: clk.now.Add(time.Minute),
})
if err != nil || claimed == nil || claimed.JobID != job.ID {
t.Skipf("could not claim this job's task (got %v, %v)", claimed, err)
}
// A partial-result artifact the coordinator stored for this task.
art := seedArtifact(t, pool, job.ID, &claimed.ID, domain.ArtifactPartialResult)
in := usecase.CompleteTaskInput{
TaskID: claimed.ID, WorkerID: "worker-1", Attempt: claimed.Attempt,
ResultArtifactID: art.ID,
}
if _, err := uc.Execute(ctx, in); err != nil {
t.Fatalf("first submission: %v", err)
}
if _, err := uc.Execute(ctx, in); err != nil {
t.Errorf("replay must be idempotent, got %v", err)
}
}
func TestPartialResultIsUniquePerTaskAttempt(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
job, tasks := seedJob(t, pool, 1)
taskID := tasks[0].ID
first := seedArtifact(t, pool, job.ID, &taskID, domain.ArtifactPartialResult)
second, err := domain.NewArtifact(job.ID, &taskID, domain.ArtifactPartialResult, "retry.csv", "text/csv", time.Now().UTC())
if err != nil {
t.Fatal(err)
}
attempt := 1
second.Attempt = &attempt
second.SetContent("other-sha", 5)
if err := NewArtifactRepo(pool).Insert(ctx, second); err == nil {
t.Fatalf("second partial artifact for %s/%d was accepted after %s", taskID, attempt, first.ID)
}
}
type fixedClock struct{ now time.Time }
func (c fixedClock) Now() time.Time { return c.now }
// seedArtifact inserts an artifact and returns it, cleaned up with its job.
func seedArtifact(t *testing.T, pool *pgxpool.Pool, jobID uuid.UUID, taskID *uuid.UUID, kind domain.ArtifactKind) *domain.Artifact {
t.Helper()
art, err := domain.NewArtifact(jobID, taskID, kind, "f.csv", "text/csv", time.Now().UTC())
if err != nil {
t.Fatalf("build artifact: %v", err)
}
art.SetContent(fmt.Sprintf("sha-%s", art.ID), 3)
if kind == domain.ArtifactPartialResult {
attempt := 1
art.Attempt = &attempt
}
if err := NewArtifactRepo(pool).Insert(context.Background(), art); err != nil {
t.Fatalf("insert artifact: %v", err)
}
return art
}
func TestWorkerRepoRoundTrip(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
repo := NewWorkerRepo(pool)
w, err := domain.NewWorker("lab-int", []string{"similarity_search", "similarity_graph"}, time.Now().UTC())
if err != nil {
t.Fatal(err)
}
if err := repo.Insert(ctx, w); err != nil {
t.Fatalf("insert: %v", err)
}
t.Cleanup(func() { _, _ = pool.Exec(context.Background(), `DELETE FROM workers WHERE id = $1`, w.ID) })
got, err := repo.Get(ctx, w.ID)
if err != nil {
t.Fatalf("get: %v", err)
}
if got.Status != domain.WorkerOnline || len(got.Capabilities) != 2 {
t.Errorf("round-trip mismatch: %+v", got)
}
// capabilities must survive the jsonb round-trip.
if got.Capabilities[0] != "similarity_search" {
t.Errorf("capabilities = %v", got.Capabilities)
}
if _, err := repo.Get(ctx, uuid.New()); !errors.Is(err, domain.ErrWorkerNotFound) {
t.Errorf("missing worker err = %v, want ErrWorkerNotFound", err)
}
}
func TestWorkerLivenessAndOfflineReaper(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
repo := NewWorkerRepo(pool)
w, err := domain.NewWorker("liveness", []string{"similarity_search"}, time.Now().UTC().Add(-time.Hour))
if err != nil {
t.Fatal(err)
}
if err := repo.Insert(ctx, w); err != nil {
t.Fatalf("insert: %v", err)
}
t.Cleanup(func() { _, _ = pool.Exec(context.Background(), `DELETE FROM workers WHERE id = $1`, w.ID) })
// A fresh heartbeat bumps it online.
now := time.Now().UTC()
if err := repo.Touch(ctx, w.ID, now); err != nil {
t.Fatalf("touch: %v", err)
}
if got, _ := repo.Get(ctx, w.ID); got.Status != domain.WorkerOnline {
t.Errorf("status = %q, want online after touch", got.Status)
}
// Touching an unregistered id is a harmless no-op.
if err := repo.Touch(ctx, uuid.New(), now); err != nil {
t.Errorf("touch of unknown worker returned %v, want nil", err)
}
// The reaper marks it offline once its heartbeat is older than the cutoff.
n, err := repo.MarkStaleOffline(ctx, now.Add(time.Minute))
if err != nil {
t.Fatalf("mark offline: %v", err)
}
if n < 1 {
t.Errorf("marked %d offline, want at least 1", n)
}
if got, _ := repo.Get(ctx, w.ID); got.Status != domain.WorkerOffline {
t.Errorf("status = %q, want offline after reaper", got.Status)
}
}
func TestArtifactRepoRoundTrip(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
job, _ := seedJob(t, pool, 1)
art := seedArtifact(t, pool, job.ID, nil, domain.ArtifactInput)
got, err := NewArtifactRepo(pool).Get(ctx, art.ID)
if err != nil {
t.Fatalf("get: %v", err)
}
if got.Kind != domain.ArtifactInput || got.StorageKey != art.StorageKey || got.SizeBytes != 3 {
t.Errorf("round-trip mismatch: %+v", got)
}
if _, err := NewArtifactRepo(pool).Get(ctx, uuid.New()); !errors.Is(err, domain.ErrArtifactNotFound) {
t.Errorf("missing artifact err = %v, want ErrArtifactNotFound", err)
}
}
func TestPartialResultArtifactRoundTripsAttempt(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
job, tasks := seedJob(t, pool, 1)
taskID := tasks[0].ID
art, err := domain.NewArtifact(job.ID, &taskID, domain.ArtifactPartialResult,
"result.csv", "text/csv", time.Now().UTC())
if err != nil {
t.Fatal(err)
}
attempt := 2
art.Attempt = &attempt
art.SetContent("sha", 3)
repo := NewArtifactRepo(pool)
if err := repo.Insert(ctx, art); err != nil {
t.Fatalf("insert: %v", err)
}
got, err := repo.Get(ctx, art.ID)
if err != nil {
t.Fatalf("get: %v", err)
}
if got.Attempt == nil || *got.Attempt != attempt {
t.Fatalf("attempt = %v, want %d", got.Attempt, attempt)
}
}
// A shard task stores its input as an artifact and no URI: this exercises the
// nullable input_uri column, the input_artifact_id round-trip, and the
// ck_tasks_has_input check that requires one or the other.
func TestShardTaskRoundTrip(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
job, err := domain.NewUploadedJob("similarity_search", nil, time.Now().UTC())
if err != nil {
t.Fatal(err)
}
jobs, taskRepo, tx := NewJobRepo(pool), NewTaskRepo(pool), NewTxManager(pool)
if err := jobs.Insert(ctx, job); err != nil {
t.Fatalf("insert job: %v", err)
}
t.Cleanup(func() { _, _ = pool.Exec(context.Background(), `DELETE FROM jobs WHERE id = $1`, job.ID) })
shard := seedArtifact(t, pool, job.ID, nil, domain.ArtifactShard)
task, err := domain.NewShardTask(job.ID, 0, "similarity_search", shard.ID, shard.SHA256, nil, 0, time.Now().UTC())
if err != nil {
t.Fatal(err)
}
if err := tx.WithinTx(ctx, func(ctx context.Context) error {
return taskRepo.InsertBatch(ctx, []*domain.Task{task})
}); err != nil {
t.Fatalf("insert shard task: %v", err)
}
got, err := taskRepo.Get(ctx, task.ID)
if err != nil {
t.Fatalf("get: %v", err)
}
if got.InputArtifactID == nil || *got.InputArtifactID != shard.ID {
t.Errorf("input_artifact_id did not round-trip: %v", got.InputArtifactID)
}
if got.InputURI != "" {
t.Errorf("shard task input_uri = %q, want empty (NULL)", got.InputURI)
}
}
func TestExpireLeasesRequeuesElapsedTasks(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
job, _ := seedJob(t, pool, 1)
repo := NewTaskRepo(pool)
past := time.Now().UTC().Add(-time.Hour)
// Lease it with an expiry already in the past.
task, err := repo.ClaimNext(ctx, usecase.ClaimFilter{
Owner: "dead-worker", Now: past, LeaseUntil: past.Add(time.Minute),
})
if err != nil || task == nil || task.JobID != job.ID {
t.Skipf("could not claim this job's task (got %v, %v)", task, err)
}
if _, err := repo.ExpireLeases(ctx, time.Now().UTC()); err != nil {
t.Fatalf("expire: %v", err)
}
counts, err := repo.CountByStatus(ctx, job.ID)
if err != nil {
t.Fatalf("count: %v", err)
}
if counts[domain.TaskPending] != 1 {
t.Errorf("pending = %d, want 1 — a dead worker must not strand its task", counts[domain.TaskPending])
}
}
func TestMigrateProvisionsAndIsIdempotent(t *testing.T) {
ctx := context.Background()
url := os.Getenv("TEST_DATABASE_URL")
if url == "" {
t.Skip("TEST_DATABASE_URL is not set")
}
if err := Migrate(ctx, url, nil); err != nil {
t.Fatalf("first migrate: %v", err)
}
if err := Migrate(ctx, url, nil); err != nil {
t.Fatalf("second migrate (idempotent): %v", err)
}
pool := testPool(t)
migrations, err := listMigrations()
if err != nil {
t.Fatal(err)
}
var watermark int64
if err := pool.QueryRow(ctx, "SELECT COALESCE(MAX(version), 0) FROM schema_migrations").Scan(&watermark); err != nil {
t.Fatalf("read schema_migrations: %v", err)
}
if watermark != int64(len(migrations)) {
t.Errorf("schema watermark = %d, want %d", watermark, len(migrations))
}
var hasJobs bool
if err := pool.QueryRow(ctx,
"SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'jobs')",
).Scan(&hasJobs); err != nil {
t.Fatal(err)
}
if !hasJobs {
t.Error("jobs table was not created by the embedded migrations")
}
}
func integrationCatalog() *workloads.Catalog {
catalog, err := workloads.Load()
if err != nil {
panic(err)
}
return catalog
}
@@ -0,0 +1,155 @@
package postgres
import (
"context"
"errors"
"time"
sq "github.com/Masterminds/squirrel"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/emil28092005/SciMesh/coordinator/internal/domain"
"github.com/emil28092005/SciMesh/coordinator/internal/usecase"
)
// JobRepo implements usecase.JobRepository.
type JobRepo struct {
pool *pgxpool.Pool
}
func NewJobRepo(pool *pgxpool.Pool) *JobRepo {
return &JobRepo{pool: pool}
}
var _ usecase.JobRepository = (*JobRepo)(nil)
var jobColumns = []string{
"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",
}
// Insert runs inside the caller's transaction, alongside the job's tasks — that
// is what makes "all tasks or none" hold.
func (r *JobRepo) Insert(ctx context.Context, j *domain.Job) error {
sql, args, err := psql.Insert("jobs").
Columns("id", "workload", "input_uri", "parameters", "status", "created_at", "owner_id").
Values(j.ID, j.Workload, j.InputURI, jsonbOrEmpty(j.Parameters), string(j.Status), j.CreatedAt, j.OwnerID).
ToSql()
if err != nil {
return err
}
_, err = conn(ctx, r.pool).Exec(ctx, sql, args...)
return err
}
func (r *JobRepo) Get(ctx context.Context, id uuid.UUID) (*domain.Job, error) {
sql, args, err := psql.Select(jobColumns...).
From("jobs").
Where(sq.Eq{"id": id}).
ToSql()
if err != nil {
return nil, err
}
var (
j domain.Job
status string
)
err = conn(ctx, r.pool).QueryRow(ctx, sql, args...).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)
if errors.Is(err, pgx.ErrNoRows) {
return nil, domain.ErrJobNotFound
}
if err != nil {
return nil, err
}
j.Status = domain.JobStatus(status)
return &j, nil
}
func (r *JobRepo) ClaimReduction(ctx context.Context, id uuid.UUID, startedAt time.Time) (bool, error) {
sql, args, err := psql.Update("jobs").
Set("reducer_started_at", startedAt).
Where(sq.Eq{"id": id, "status": string(domain.JobReducing), "reducer_started_at": nil}).
ToSql()
if err != nil {
return false, err
}
tag, err := conn(ctx, r.pool).Exec(ctx, sql, args...)
if err != nil {
return false, err
}
return tag.RowsAffected() == 1, nil
}
func (r *JobRepo) CompleteWithResult(ctx context.Context, id, resultArtifactID uuid.UUID, completedAt time.Time) error {
sql, args, err := psql.Update("jobs").
SetMap(map[string]any{
"status": string(domain.JobCompleted),
"result_artifact_id": resultArtifactID,
"completed_at": completedAt,
"reducer_started_at": nil,
"error_code": nil,
"error_message": nil,
}).
Where(sq.Eq{"id": id, "status": string(domain.JobReducing)}).
ToSql()
if err != nil {
return err
}
tag, err := conn(ctx, r.pool).Exec(ctx, sql, args...)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return domain.ErrJobNotFound
}
return nil
}
func (r *JobRepo) FailReduction(ctx context.Context, id uuid.UUID, code, message string, completedAt time.Time) error {
sql, args, err := psql.Update("jobs").
SetMap(map[string]any{
"status": string(domain.JobFailed),
"completed_at": completedAt,
"error_code": code,
"error_message": message,
"reducer_started_at": nil,
}).
Where(sq.Eq{"id": id, "status": string(domain.JobReducing)}).
ToSql()
if err != nil {
return err
}
_, err = conn(ctx, r.pool).Exec(ctx, sql, args...)
return err
}
func (r *JobRepo) UpdateStatus(ctx context.Context, id uuid.UUID,
status domain.JobStatus, completedAt *time.Time) error {
sql, args, err := psql.Update("jobs").
SetMap(map[string]any{
"status": string(status),
"completed_at": completedAt,
}).
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 err
}
if tag.RowsAffected() == 0 {
return domain.ErrJobNotFound
}
return nil
}
@@ -0,0 +1,144 @@
package postgres
import (
"context"
"embed"
"fmt"
"log/slog"
"regexp"
"sort"
"strconv"
"github.com/jackc/pgx/v5"
)
//go:embed migrations/*.sql
var migrationFiles embed.FS
var migrationNamePattern = regexp.MustCompile(`^([0-9]+)_[a-z0-9_]+\.(up|down)\.sql$`)
// migration is one parsed embedded migration file.
type migration struct {
version int
name string
sql string
}
// listMigrations parses and orders the embedded .up.sql files by version.
func listMigrations() ([]migration, error) {
entries, err := migrationFiles.ReadDir("migrations")
if err != nil {
return nil, fmt.Errorf("read embedded migrations: %w", err)
}
up := map[int]migration{}
for _, entry := range entries {
match := migrationNamePattern.FindStringSubmatch(entry.Name())
if match == nil {
continue
}
if match[2] != "up" {
continue
}
version, err := strconv.Atoi(match[1])
if err != nil {
return nil, fmt.Errorf("migration %q has an invalid version: %w", entry.Name(), err)
}
if _, duplicate := up[version]; duplicate {
return nil, fmt.Errorf("migration version %d is duplicated", version)
}
body, err := migrationFiles.ReadFile("migrations/" + entry.Name())
if err != nil {
return nil, fmt.Errorf("read migration %q: %w", entry.Name(), err)
}
up[version] = migration{version: version, name: entry.Name(), sql: string(body)}
}
if len(up) == 0 {
return nil, fmt.Errorf("no .up.sql migrations are embedded")
}
versions := make([]int, 0, len(up))
for version := range up {
versions = append(versions, version)
}
sort.Ints(versions)
migrations := make([]migration, 0, len(versions))
for _, version := range versions {
migrations = append(migrations, up[version])
}
for index, item := range migrations {
if item.version != index+1 {
return nil, fmt.Errorf("embedded migrations are not contiguous: version %d at position %d", item.version, index+1)
}
}
return migrations, nil
}
// Migrate applies every embedded migration above the recorded schema version,
// so the binary provisions its own schema. It is idempotent and interoperates
// with the golang-migrate CLI: both tools use the same schema_migrations
// watermark table (single row: version + dirty flag), and a PostgreSQL
// advisory lock serializes concurrent migrators. Each migration file runs as
// its own transaction (the files carry explicit BEGIN/COMMIT, matching the
// golang-migrate format the CLI and CI still use).
func Migrate(ctx context.Context, databaseURL string, log *slog.Logger) error {
migrations, err := listMigrations()
if err != nil {
return err
}
connConfig, err := pgx.ParseConfig(databaseURL)
if err != nil {
return fmt.Errorf("parse database url: %w", err)
}
// Migration files contain multiple statements (BEGIN...COMMIT), which the
// extended query protocol rejects; run them with the simple protocol.
connConfig.DefaultQueryExecMode = pgx.QueryExecModeSimpleProtocol
conn, err := pgx.ConnectConfig(ctx, connConfig)
if err != nil {
return fmt.Errorf("connect for migration: %w", err)
}
defer func() { _ = conn.Close(ctx) }()
if _, err := conn.Exec(ctx, "SELECT pg_advisory_lock(82473911)"); err != nil {
return fmt.Errorf("acquire migration lock: %w", err)
}
defer func() { _, _ = conn.Exec(ctx, "SELECT pg_advisory_unlock(82473911)") }()
if _, err := conn.Exec(ctx,
"CREATE TABLE IF NOT EXISTS schema_migrations (version bigint PRIMARY KEY, dirty boolean NOT NULL DEFAULT false)",
); err != nil {
return fmt.Errorf("ensure schema_migrations: %w", err)
}
var applied int64
if err := conn.QueryRow(ctx,
"SELECT COALESCE(MAX(version), 0) FROM schema_migrations",
).Scan(&applied); err != nil {
return fmt.Errorf("read applied schema version: %w", err)
}
for _, item := range migrations {
if int64(item.version) <= applied {
continue
}
if log != nil {
log.Info("applying migration", "version", item.version, "file", item.name)
}
if _, err := conn.Exec(ctx, item.sql); err != nil {
return fmt.Errorf("apply migration %s: %w", item.name, err)
}
// Advance the watermark to the single-row golang-migrate layout.
tag, err := conn.Exec(ctx,
"UPDATE schema_migrations SET version = $1, dirty = false", item.version)
if err != nil {
return fmt.Errorf("record migration %s: %w", item.name, err)
}
if tag.RowsAffected() == 0 {
if _, err := conn.Exec(ctx,
"INSERT INTO schema_migrations (version, dirty) VALUES ($1, false)",
item.version,
); err != nil {
return fmt.Errorf("record migration %s: %w", item.name, err)
}
}
}
return nil
}
@@ -0,0 +1,62 @@
package postgres
import (
"strings"
"testing"
)
func TestListMigrationsParsesAndOrdersEmbeddedFiles(t *testing.T) {
migrations, err := listMigrations()
if err != nil {
t.Fatalf("list migrations: %v", err)
}
if len(migrations) == 0 {
t.Fatal("no embedded migrations")
}
for index, item := range migrations {
if item.version != index+1 {
t.Errorf("migration %d has version %d, want contiguous ordering", index, item.version)
}
if item.name != expectedMigrationName(item.version) {
t.Errorf("migration %d file is %q, want %q", item.version, item.name, expectedMigrationName(item.version))
}
if strings.TrimSpace(item.sql) == "" {
t.Errorf("migration %d is empty", item.version)
}
}
}
func expectedMigrationName(version int) string {
switch version {
case 1:
return "0001_init.up.sql"
case 2:
return "0002_workers.up.sql"
case 3:
return "0003_artifacts.up.sql"
case 4:
return "0004_result_artifact.up.sql"
case 5:
return "0005_uploaded_input.up.sql"
case 6:
return "0006_task_running_enum.up.sql"
case 7:
return "0007_task_running_lease.up.sql"
case 8:
return "0008_artifact_attempt.up.sql"
case 9:
return "0009_unique_partial_result_attempt.up.sql"
case 10:
return "0010_job_reduction.up.sql"
case 11:
return "0011_job_owner.up.sql"
case 12:
return "0012_worker_trust.up.sql"
case 13:
return "0013_task_results.up.sql"
case 14:
return "0014_workload_settings.up.sql"
default:
return ""
}
}
@@ -0,0 +1,8 @@
BEGIN;
DROP TABLE IF EXISTS tasks;
DROP TABLE IF EXISTS jobs;
DROP TYPE IF EXISTS task_status;
DROP TYPE IF EXISTS job_status;
COMMIT;
@@ -0,0 +1,58 @@
BEGIN;
CREATE TYPE job_status AS ENUM ('pending','running','completed','failed','cancelled');
CREATE TYPE task_status AS ENUM ('pending','leased','completed','failed','cancelled');
-- One user submission, possibly split into several tasks.
CREATE TABLE jobs (
id uuid PRIMARY KEY,
workload text NOT NULL,
input_uri text NOT NULL,
parameters jsonb NOT NULL DEFAULT '{}'::jsonb,
status job_status NOT NULL DEFAULT 'pending',
created_at timestamptz NOT NULL DEFAULT now(),
completed_at timestamptz
);
-- One independently executable chunk.
CREATE TABLE tasks (
id uuid PRIMARY KEY,
job_id uuid NOT NULL REFERENCES jobs(id) ON DELETE CASCADE,
chunk_index integer NOT NULL,
workload text NOT NULL,
input_uri text NOT NULL,
input_sha256 text NOT NULL,
parameters jsonb NOT NULL DEFAULT '{}'::jsonb,
status task_status NOT NULL DEFAULT 'pending',
attempt integer NOT NULL DEFAULT 0,
max_attempts integer NOT NULL DEFAULT 3,
lease_owner text,
lease_expires_at timestamptz,
result_uri text,
result_sha256 text,
metrics jsonb,
error_code text,
error_message text,
created_at timestamptz NOT NULL DEFAULT now(),
started_at timestamptz,
completed_at timestamptz,
version integer NOT NULL DEFAULT 0,
CONSTRAINT uq_tasks_job_chunk UNIQUE (job_id, chunk_index),
CONSTRAINT ck_tasks_attempt CHECK (attempt >= 0),
CONSTRAINT ck_tasks_max_attempts CHECK (max_attempts > 0),
-- A completed task must carry its result manifest.
CONSTRAINT ck_tasks_completed_result CHECK (
status <> 'completed' OR (result_uri IS NOT NULL AND result_sha256 IS NOT NULL)
),
-- A leased task must carry its lease.
CONSTRAINT ck_tasks_leased_owner CHECK (
status <> 'leased' OR (lease_owner IS NOT NULL AND lease_expires_at IS NOT NULL)
)
);
-- Claim path: find the oldest pending task fast.
CREATE INDEX ix_tasks_claim ON tasks (status, lease_expires_at, created_at);
CREATE INDEX ix_tasks_job ON tasks (job_id);
COMMIT;
@@ -0,0 +1,6 @@
BEGIN;
DROP TABLE IF EXISTS workers;
DROP TYPE IF EXISTS worker_status;
COMMIT;
@@ -0,0 +1,20 @@
BEGIN;
CREATE TYPE worker_status AS ENUM ('online','busy','offline');
-- A registered process/machine that can claim tasks. Registration returns the
-- id; liveness is tracked by last_heartbeat_at.
CREATE TABLE workers (
id uuid PRIMARY KEY,
name text NOT NULL DEFAULT '',
capabilities jsonb NOT NULL DEFAULT '[]'::jsonb,
status worker_status NOT NULL DEFAULT 'online',
last_heartbeat_at timestamptz NOT NULL DEFAULT now(),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
-- Liveness sweep: find workers that have gone quiet.
CREATE INDEX ix_workers_liveness ON workers (status, last_heartbeat_at);
COMMIT;
@@ -0,0 +1,11 @@
BEGIN;
ALTER TABLE tasks DROP COLUMN IF EXISTS input_artifact_id;
ALTER TABLE tasks DROP COLUMN IF EXISTS result_artifact_id;
ALTER TABLE jobs DROP COLUMN IF EXISTS input_artifact_id;
ALTER TABLE jobs DROP COLUMN IF EXISTS result_artifact_id;
DROP TABLE IF EXISTS artifacts;
DROP TYPE IF EXISTS artifact_kind;
COMMIT;
@@ -0,0 +1,31 @@
BEGIN;
CREATE TYPE artifact_kind AS ENUM ('input','shard','partial_result','final_result','log');
-- A durable file the coordinator owns: input, shard, partial/final result, log.
-- The database is the source of truth; files are found through this metadata,
-- never by scanning directories.
CREATE TABLE artifacts (
id uuid PRIMARY KEY,
job_id uuid NOT NULL REFERENCES jobs(id) ON DELETE CASCADE,
task_id uuid REFERENCES tasks(id) ON DELETE CASCADE, -- null for job-level inputs
kind artifact_kind NOT NULL,
filename text NOT NULL,
storage_key text NOT NULL UNIQUE, -- coordinator-generated, never a client path
content_type text NOT NULL DEFAULT 'application/octet-stream',
size_bytes bigint NOT NULL CHECK (size_bytes >= 0),
sha256 text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX ix_artifacts_job ON artifacts (job_id);
CREATE INDEX ix_artifacts_task ON artifacts (task_id);
-- Jobs and tasks reference their artifacts. Nullable during the transition from
-- URI-based inputs/results to artifact-based ones.
ALTER TABLE jobs ADD COLUMN input_artifact_id uuid REFERENCES artifacts(id);
ALTER TABLE jobs ADD COLUMN result_artifact_id uuid REFERENCES artifacts(id);
ALTER TABLE tasks ADD COLUMN input_artifact_id uuid REFERENCES artifacts(id);
ALTER TABLE tasks ADD COLUMN result_artifact_id uuid REFERENCES artifacts(id);
COMMIT;
@@ -0,0 +1,11 @@
BEGIN;
ALTER TABLE tasks DROP CONSTRAINT IF EXISTS ck_tasks_completed_result;
ALTER TABLE tasks ADD COLUMN result_uri text;
ALTER TABLE tasks ADD COLUMN result_sha256 text;
ALTER TABLE tasks ADD CONSTRAINT ck_tasks_completed_result CHECK (
status <> 'completed' OR (result_uri IS NOT NULL AND result_sha256 IS NOT NULL)
);
COMMIT;
@@ -0,0 +1,14 @@
BEGIN;
-- Results are now coordinator-owned artifacts, not worker-supplied URIs.
-- Drop the URI-based completion guard and columns, and require a completed task
-- to reference its result artifact instead (PLAN.md §6.2).
ALTER TABLE tasks DROP CONSTRAINT IF EXISTS ck_tasks_completed_result;
ALTER TABLE tasks DROP COLUMN IF EXISTS result_uri;
ALTER TABLE tasks DROP COLUMN IF EXISTS result_sha256;
ALTER TABLE tasks ADD CONSTRAINT ck_tasks_completed_result CHECK (
status <> 'completed' OR result_artifact_id IS NOT NULL
);
COMMIT;
@@ -0,0 +1,9 @@
BEGIN;
ALTER TABLE tasks DROP CONSTRAINT IF EXISTS ck_tasks_has_input;
-- Restoring NOT NULL requires the columns to be populated; safe on a fresh DB.
ALTER TABLE tasks ALTER COLUMN input_uri SET NOT NULL;
ALTER TABLE jobs ALTER COLUMN input_uri SET NOT NULL;
COMMIT;
@@ -0,0 +1,13 @@
BEGIN;
-- Inputs can now arrive as uploaded artifacts (POST /jobs/upload), not only as
-- external URIs. Relax the URI requirement and require every task to have an
-- input one way or the other.
ALTER TABLE jobs ALTER COLUMN input_uri DROP NOT NULL;
ALTER TABLE tasks ALTER COLUMN input_uri DROP NOT NULL;
ALTER TABLE tasks ADD CONSTRAINT ck_tasks_has_input CHECK (
input_uri IS NOT NULL OR input_artifact_id IS NOT NULL
);
COMMIT;
@@ -0,0 +1,4 @@
-- PostgreSQL cannot drop a single enum value without recreating the type and
-- rewriting every dependent column. Leaving 'running' in place is harmless: no
-- code writes it after the down of 0007 restores the leased-only transitions.
SELECT 1;

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