flashruntime 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. flashml_workloads/__init__.py +7 -0
  2. flashml_workloads/fedavg_driver.py +569 -0
  3. flashml_workloads/fedavg_weights.py +223 -0
  4. flashml_workloads/fedavg_worker.py +166 -0
  5. flashml_workloads/kmeans_driver.py +134 -0
  6. flashml_workloads/kmeans_shard.py +69 -0
  7. flashml_workloads/sgd_trainer.py +127 -0
  8. flashml_workloads/sharded_kmeans.py +323 -0
  9. flashml_workloads/sklearn_trial.py +89 -0
  10. flashruntime/__init__.py +125 -0
  11. flashruntime/artifacts/__init__.py +25 -0
  12. flashruntime/artifacts/store.py +228 -0
  13. flashruntime/backends/__init__.py +26 -0
  14. flashruntime/backends/base.py +63 -0
  15. flashruntime/backends/kuberay.py +465 -0
  16. flashruntime/checkpoint/__init__.py +20 -0
  17. flashruntime/checkpoint/catalog.py +198 -0
  18. flashruntime/checkpoint/local.py +109 -0
  19. flashruntime/checkpoint/store.py +86 -0
  20. flashruntime/integrations/__init__.py +5 -0
  21. flashruntime/integrations/huggingface.py +59 -0
  22. flashruntime/integrations/pytorch.py +52 -0
  23. flashruntime/integrations/sklearn.py +42 -0
  24. flashruntime/launchers/__init__.py +130 -0
  25. flashruntime/launchers/local.py +126 -0
  26. flashruntime/leases/__init__.py +27 -0
  27. flashruntime/leases/manager.py +365 -0
  28. flashruntime/leases/sqlite_store.py +169 -0
  29. flashruntime/leases/store.py +103 -0
  30. flashruntime/monitor/__init__.py +7 -0
  31. flashruntime/monitor/sampler.py +232 -0
  32. flashruntime/planner/__init__.py +56 -0
  33. flashruntime/planner/candidates.py +597 -0
  34. flashruntime/planner/catalog.py +129 -0
  35. flashruntime/planner/comm.py +95 -0
  36. flashruntime/planner/explain.py +109 -0
  37. flashruntime/planner/memory.py +166 -0
  38. flashruntime/planner/resolve.py +120 -0
  39. flashruntime/planner/selector.py +169 -0
  40. flashruntime/planner/timecost.py +81 -0
  41. flashruntime/profiling/__init__.py +113 -0
  42. flashruntime/protocol/__init__.py +18 -0
  43. flashruntime/protocol/plan_v1alpha1.py +320 -0
  44. flashruntime/protocol/v1alpha1.py +465 -0
  45. flashruntime/providers/__init__.py +138 -0
  46. flashruntime/py.typed +0 -0
  47. flashruntime/recipes/__init__.py +135 -0
  48. flashruntime/recipes/command.py +166 -0
  49. flashruntime/recovery/__init__.py +21 -0
  50. flashruntime/recovery/policy.py +170 -0
  51. flashruntime/recovery/signals.py +135 -0
  52. flashruntime/recovery/taxonomy.py +91 -0
  53. flashruntime/scheduler/__init__.py +170 -0
  54. flashruntime/sdk.py +402 -0
  55. flashruntime/service/__init__.py +3 -0
  56. flashruntime/service/app.py +391 -0
  57. flashruntime/service/auth.py +180 -0
  58. flashruntime/service/checkpoints.py +90 -0
  59. flashruntime/service/cli.py +167 -0
  60. flashruntime/service/dashboard.py +193 -0
  61. flashruntime/service/ledger.py +101 -0
  62. flashruntime/service/modea.py +821 -0
  63. flashruntime/strategies/__init__.py +156 -0
  64. flashruntime/strategies/command.py +56 -0
  65. flashruntime/torch/__init__.py +274 -0
  66. flashruntime/viewer/__init__.py +20 -0
  67. flashruntime/viewer/_docs/benchmarks.html +771 -0
  68. flashruntime/viewer/_docs/concepts/architecture.html +302 -0
  69. flashruntime/viewer/_docs/get-started.html +263 -0
  70. flashruntime/viewer/_docs/guides/federated-averaging.html +363 -0
  71. flashruntime/viewer/_docs/guides/huggingface.html +223 -0
  72. flashruntime/viewer/_docs/guides/jobspec-and-isolation.html +271 -0
  73. flashruntime/viewer/_docs/guides/pytorch.html +313 -0
  74. flashruntime/viewer/_docs/guides/sklearn.html +232 -0
  75. flashruntime/viewer/_docs/index.html +251 -0
  76. flashruntime/viewer/_docs/reference/cli.html +254 -0
  77. flashruntime/viewer/_docs/reference/integrations.html +240 -0
  78. flashruntime/viewer/_docs/reference/sdk.html +341 -0
  79. flashruntime/viewer/_docs/reference/torch-helper.html +244 -0
  80. flashruntime/viewer/_docs/search-index.json +1 -0
  81. flashruntime/viewer/_docs/tutorials/convnet.html +571 -0
  82. flashruntime/viewer/_docs/tutorials/fault-tolerance.html +375 -0
  83. flashruntime/viewer/_docs/tutorials/sklearn-sweeps.html +278 -0
  84. flashruntime/viewer/flowmap.py +307 -0
  85. flashruntime/viewer/page.py +594 -0
  86. flashruntime/viewer/server.py +134 -0
  87. flashruntime/viewer/state.py +250 -0
  88. flashruntime/workloads/__init__.py +6 -0
  89. flashruntime/workloads/command.py +127 -0
  90. flashruntime-0.3.0.dist-info/METADATA +365 -0
  91. flashruntime-0.3.0.dist-info/RECORD +95 -0
  92. flashruntime-0.3.0.dist-info/WHEEL +5 -0
  93. flashruntime-0.3.0.dist-info/entry_points.txt +2 -0
  94. flashruntime-0.3.0.dist-info/licenses/LICENSE +202 -0
  95. flashruntime-0.3.0.dist-info/top_level.txt +2 -0
@@ -0,0 +1,821 @@
1
+ """Mode A over HTTP: the lease coordinator, node registry, and local
2
+ artifact hosting for the self-hosted profile.
3
+
4
+ This module makes the pure-library pieces reachable by remote workers:
5
+
6
+ - **Leases** — HTTP verbs over the `LeaseManager` (claim / heartbeat /
7
+ complete / fail) plus a background sweep. FlashNode's executor is the
8
+ intended client, but anything that speaks the protocol can pull work.
9
+ - **Node registry** — minimal register/heartbeat/list so a self-hosted
10
+ coordinator knows its workers (FlashML Cloud fronts this with join codes
11
+ and trust tiers in the managed product; the wire models are the same).
12
+ - **Local artifacts** — PUT/GET raw bytes under a local directory, so
13
+ shared data (datasets in, trial outputs back) needs no cloud and no
14
+ MinIO: the coordinator *is* the artifact host for the local loop. Keys
15
+ are sha256-verified on upload; the same `artifact://` URIs used by the
16
+ cloud stores apply, keeping job specs portable.
17
+
18
+ Job → task expansion lives here too: a JobSpec with
19
+ `execution.backend: leases` and `workload.type: hyperparameter_search`
20
+ becomes N `TaskSpec`s whose payloads carry the executor contract
21
+ (module, params, input artifact keys, output prefix).
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import hashlib
27
+ import itertools
28
+ import math
29
+ from datetime import datetime, timezone
30
+ from pathlib import Path
31
+
32
+ from fastapi import APIRouter, HTTPException, Request, Response
33
+ from pydantic import BaseModel
34
+
35
+ import flashruntime.recipes.command # noqa: F401 — registers the "command" recipe
36
+ from flashruntime.leases import LeaseManager
37
+ from flashruntime.protocol.v1alpha1 import (
38
+ ArtifactRecord,
39
+ JobSpec,
40
+ NodeHeartbeat,
41
+ NodeRegistration,
42
+ TaskSpec,
43
+ TaskState,
44
+ )
45
+ from flashruntime.recipes import recipe_for
46
+ from flashruntime.scheduler import IsolationAwarePlacement
47
+
48
+ NODE_OFFLINE_AFTER_S = 15.0
49
+
50
+ # Task modules the *coordinator* will hand out. The executor enforces its own
51
+ # allowlist too — both ends fail closed.
52
+ ALLOWED_TASK_MODULES = {
53
+ "flashml_workloads.sklearn_trial",
54
+ "flashml_workloads.kmeans_shard",
55
+ "flashml_workloads.sgd_trainer",
56
+ "flashml_workloads.fedavg_worker",
57
+ }
58
+
59
+
60
+ #: Longest lease a submitter may ask for. A lease deadline is the ONLY thing
61
+ #: that returns an abandoned task to the queue, so an unbounded
62
+ #: `lease_seconds` is a denial-of-service knob: `1e9` pins a task to a
63
+ #: machine that closed its laptop for ~31 years, and `float("inf")` does not
64
+ #: even survive the claim path (`timedelta(seconds=inf)` raises OverflowError
65
+ #: *inside* the coordinator). One hour is far above any real task here and
66
+ #: far below "forever".
67
+ MAX_LEASE_SECONDS = 3600.0
68
+
69
+ #: The worker params `_expand_fedavg` must forward. Single source of truth so
70
+ #: the expansion and `flashml_workloads.fedavg_worker`'s reads cannot drift —
71
+ #: `tests/test_service_fedavg.py` binds this tuple to the worker's actual
72
+ #: parameter accesses by parsing its source.
73
+ FEDAVG_WORKER_PARAM_KEYS = ("local_steps", "lr", "batch_size", "seed",
74
+ "in_dim", "hidden", "out_dim", "dataset_size")
75
+
76
+ #: Params `_expand_fedavg` computes itself per task, so the submitter does
77
+ #: not supply them even though the worker reads them.
78
+ FEDAVG_DRIVER_SUPPLIED_KEYS = ("round", "shard", "num_shards")
79
+
80
+
81
+ class ExpansionError(ValueError):
82
+ """The JobSpec cannot be expanded into tasks (bad workload/parameters)."""
83
+
84
+
85
+ def _lease_seconds(params: dict, default: float) -> float:
86
+ """Validated, clamped `lease_seconds` from submitter-supplied params."""
87
+ raw = params.get("lease_seconds", default)
88
+ try:
89
+ value = float(raw)
90
+ except (TypeError, ValueError):
91
+ raise ExpansionError(
92
+ f"lease_seconds must be a number, got {raw!r}"
93
+ ) from None
94
+ if not math.isfinite(value):
95
+ raise ExpansionError(
96
+ f"lease_seconds must be finite, got {raw!r} (a non-finite lease "
97
+ "deadline overflows timedelta in the claim path)"
98
+ )
99
+ if value <= 0:
100
+ raise ExpansionError(f"lease_seconds must be > 0, got {value}")
101
+ return min(value, MAX_LEASE_SECONDS)
102
+
103
+
104
+ def expand_tasks(job_id: str, spec: JobSpec) -> list[TaskSpec]:
105
+ """Turn a lease-mode JobSpec into independent TaskSpecs.
106
+
107
+ hyperparameter_search parameters:
108
+ trials: explicit list of param dicts, or
109
+ grid: {param: [values, ...]} — cartesian product
110
+ module: task module (allowlisted; default sklearn_trial)
111
+ inputs: {name: "artifact://key"} shared data, downloaded per task
112
+ lease_seconds: heartbeat window per attempt (default 60)
113
+ """
114
+ workload = spec.spec.workload
115
+ try:
116
+ recipe = recipe_for(workload.type)
117
+ except LookupError:
118
+ recipe = None
119
+ if recipe is not None:
120
+ try:
121
+ return recipe.expand(job_id, spec)
122
+ except ValueError as exc:
123
+ raise ExpansionError(str(exc)) from None
124
+ if workload.type == "sharded_kmeans":
125
+ return _expand_kmeans(job_id, spec)
126
+ if workload.type == "federated_averaging":
127
+ return _expand_fedavg(job_id, spec)
128
+ if workload.type != "hyperparameter_search":
129
+ raise ExpansionError(
130
+ f"lease backend supports workload types 'hyperparameter_search', "
131
+ f"'sharded_kmeans' and 'federated_averaging', got '{workload.type}'"
132
+ )
133
+ p = workload.parameters
134
+ trials: list[dict] = list(p.get("trials") or [])
135
+ if not trials and p.get("grid"):
136
+ grid: dict[str, list] = p["grid"]
137
+ keys = sorted(grid)
138
+ trials = [dict(zip(keys, combo)) for combo in itertools.product(*(grid[k] for k in keys))]
139
+ if not trials:
140
+ raise ExpansionError("hyperparameter_search needs 'trials' (list) or 'grid' (dict of lists)")
141
+
142
+ module = p.get("module", "flashml_workloads.sklearn_trial")
143
+ if module not in ALLOWED_TASK_MODULES:
144
+ raise ExpansionError(f"task module '{module}' is not allowlisted")
145
+ inputs = dict(p.get("inputs") or {})
146
+ for name, uri in inputs.items():
147
+ if not str(uri).startswith("artifact://"):
148
+ raise ExpansionError(f"input '{name}' must be an artifact:// URI, got {uri!r}")
149
+
150
+ checkpoint = p.get("checkpoint") # non-None turns the executor's relay on
151
+ # Stamp the isolation requirement so the placement gate can fail closed —
152
+ # a sandboxed job must never lease to a non-sandbox node (mirrors
153
+ # recipes/command.py; the legacy expansions were dropping this).
154
+ isolation = {
155
+ "tier": spec.spec.isolation.tier,
156
+ "allowFallback": spec.spec.isolation.allowFallback,
157
+ }
158
+ tasks = []
159
+ for i, params in enumerate(trials):
160
+ task_id = f"trial-{i:03d}"
161
+ payload = {
162
+ "module": module,
163
+ "params": params,
164
+ "inputs": inputs,
165
+ "output_prefix": f"jobs/{job_id}/{task_id}/",
166
+ "task_id": task_id,
167
+ # the docker-runner tier resolves and allowlists this
168
+ "image": spec.spec.image.reference,
169
+ "isolation": isolation,
170
+ }
171
+ if checkpoint is not None:
172
+ payload["checkpoint"] = checkpoint
173
+ tasks.append(
174
+ TaskSpec(
175
+ task_id=task_id,
176
+ job_id=job_id,
177
+ commit_key=f"jobs/{job_id}/{task_id}/metrics.json",
178
+ max_attempts=spec.spec.retryPolicy.maxTaskAttempts,
179
+ lease_seconds=_lease_seconds(p, 60.0),
180
+ payload=payload,
181
+ )
182
+ )
183
+ return tasks
184
+
185
+
186
+ def _expand_kmeans(job_id: str, spec: JobSpec) -> list[TaskSpec]:
187
+ """One K-means *iteration*: one task per data shard, each computing
188
+ partial sums against the broadcast centroids. The driver
189
+ (`flashml_workloads.kmeans_driver`) reduces and submits the next
190
+ iteration as a new job — stage composition, not a new execution mode."""
191
+ p = spec.spec.workload.parameters
192
+ shards: list[str] = list(p.get("shards") or [])
193
+ centroids = p.get("centroids")
194
+ if not shards or not centroids:
195
+ raise ExpansionError("sharded_kmeans needs 'shards' (artifact:// list) and 'centroids'")
196
+ for uri in shards:
197
+ if not str(uri).startswith("artifact://"):
198
+ raise ExpansionError(f"shard must be an artifact:// URI, got {uri!r}")
199
+ iteration = int(p.get("iteration", 0))
200
+ # Same fail-closed stamp as the hyperparameter_search path (mirrors
201
+ # recipes/command.py) — without it a sandboxed job leases anywhere.
202
+ isolation = {
203
+ "tier": spec.spec.isolation.tier,
204
+ "allowFallback": spec.spec.isolation.allowFallback,
205
+ }
206
+
207
+ tasks = []
208
+ for i, shard_uri in enumerate(shards):
209
+ task_id = f"it{iteration:02d}-shard-{i:03d}"
210
+ tasks.append(
211
+ TaskSpec(
212
+ task_id=task_id,
213
+ job_id=job_id,
214
+ commit_key=f"jobs/{job_id}/{task_id}/metrics.json",
215
+ max_attempts=spec.spec.retryPolicy.maxTaskAttempts,
216
+ lease_seconds=_lease_seconds(p, 60.0),
217
+ payload={
218
+ "module": "flashml_workloads.kmeans_shard",
219
+ "params": {"centroids": centroids},
220
+ "inputs": {"shard": shard_uri},
221
+ "output_prefix": f"jobs/{job_id}/{task_id}/",
222
+ "task_id": task_id,
223
+ "image": spec.spec.image.reference,
224
+ "isolation": isolation,
225
+ },
226
+ )
227
+ )
228
+ return tasks
229
+
230
+
231
+ def _expand_fedavg(job_id: str, spec: JobSpec) -> list[TaskSpec]:
232
+ """One federated-averaging *round*: one task per shard, each training
233
+ locally from the round's broadcast weights. The driver
234
+ (`flashml_workloads.fedavg_driver`) reduces the deltas and submits the
235
+ next round — same stage-composition pattern as `_expand_kmeans`.
236
+ """
237
+ p = spec.spec.workload.parameters
238
+ num_shards = int(p.get("num_shards", 0))
239
+ if num_shards < 1 or num_shards > 999:
240
+ raise ExpansionError(
241
+ f"federated_averaging needs 1 <= num_shards <= 999, got {num_shards} "
242
+ "(task ids are zero-padded to 3 digits and are sorted as strings)"
243
+ )
244
+
245
+ inputs: dict[str, str] = {}
246
+ weights = p.get("weights")
247
+ if weights is not None:
248
+ if not str(weights).startswith("artifact://"):
249
+ raise ExpansionError(
250
+ f"input 'weights' must be an artifact:// URI, got {weights!r}"
251
+ )
252
+ inputs["weights"] = weights
253
+
254
+ isolation = {
255
+ "tier": spec.spec.isolation.tier,
256
+ "allowFallback": spec.spec.isolation.allowFallback,
257
+ }
258
+ # Every one of these is read unconditionally by fedavg_worker. Dropping a
259
+ # missing key here would defer the failure to a KeyError inside a container
260
+ # on a volunteer's machine, where it burns an attempt and reads as a node
261
+ # fault rather than a bad submission. Fail at expansion instead.
262
+ worker_keys = FEDAVG_WORKER_PARAM_KEYS
263
+ missing = [k for k in worker_keys if k not in p]
264
+ if missing:
265
+ raise ExpansionError(
266
+ f"federated_averaging is missing required parameters: {sorted(missing)}"
267
+ )
268
+
269
+ tasks = []
270
+ for shard in range(num_shards):
271
+ task_id = f"shard-{shard:03d}"
272
+ params = {k: p[k] for k in worker_keys}
273
+ params.update({"round": int(p.get("round", 0)),
274
+ "shard": shard, "num_shards": num_shards})
275
+ tasks.append(
276
+ TaskSpec(
277
+ task_id=task_id,
278
+ job_id=job_id,
279
+ commit_key=f"jobs/{job_id}/{task_id}/metrics.json",
280
+ max_attempts=spec.spec.retryPolicy.maxTaskAttempts,
281
+ lease_seconds=_lease_seconds(p, 120.0),
282
+ payload={
283
+ "module": "flashml_workloads.fedavg_worker",
284
+ "params": params,
285
+ "inputs": inputs,
286
+ "output_prefix": f"jobs/{job_id}/{task_id}/",
287
+ "task_id": task_id,
288
+ "image": spec.spec.image.reference,
289
+ "isolation": isolation,
290
+ },
291
+ )
292
+ )
293
+ return tasks
294
+
295
+
296
+ class _NodeEntry(BaseModel):
297
+ registration: NodeRegistration
298
+ last_heartbeat: datetime
299
+ accepted_tasks: int = 0
300
+
301
+
302
+ class ModeAState:
303
+ """Shared state behind the router. In-memory by design for the local
304
+ loop (the ledger keeps the durable event history); the Stage-6 upgrade
305
+ swaps the store for Postgres without touching the endpoints."""
306
+
307
+ def __init__(
308
+ self,
309
+ manager: LeaseManager,
310
+ artifacts_dir: Path,
311
+ join_code: str | None = None,
312
+ max_artifact_bytes: int = 256 * 1024 * 1024,
313
+ authenticator: NodeAuthenticator | None = None,
314
+ ):
315
+ self.manager = manager
316
+ self.artifacts_dir = artifacts_dir
317
+ self.join_code = join_code # None = open registration (self-hosted default)
318
+ self.max_artifact_bytes = max_artifact_bytes
319
+ self.nodes: dict[str, _NodeEntry] = {}
320
+ self.lease_jobs: set[str] = set() # job_ids running on the lease path
321
+ from flashruntime.service.auth import NodeAuthenticator, authenticator_from_env
322
+
323
+ self.authenticator: NodeAuthenticator = authenticator or authenticator_from_env()
324
+
325
+ def node_view(self) -> list[dict]:
326
+ now = datetime.now(timezone.utc)
327
+ out = []
328
+ for entry in self.nodes.values():
329
+ age = (now - entry.last_heartbeat).total_seconds()
330
+ out.append(
331
+ {
332
+ "node_id": entry.registration.node_id,
333
+ "hostname": entry.registration.hostname,
334
+ "environment": entry.registration.environment,
335
+ "argv_capable": entry.registration.argv_capable,
336
+ "module_capable": entry.registration.module_capable,
337
+ "capabilities": entry.registration.capabilities.model_dump(),
338
+ "online": age < NODE_OFFLINE_AFTER_S,
339
+ "last_heartbeat_age_s": round(age, 1),
340
+ "accepted_tasks": entry.accepted_tasks,
341
+ }
342
+ )
343
+ return sorted(out, key=lambda n: n["node_id"])
344
+
345
+
346
+ class ClaimRequest(BaseModel):
347
+ node_id: str
348
+ job_id: str | None = None
349
+
350
+
351
+ class CompleteRequest(BaseModel):
352
+ output_sha256: str
353
+
354
+
355
+ class FailRequest(BaseModel):
356
+ reason: str
357
+
358
+
359
+ def _output_valid(artifacts_dir: Path, commit_key: str, claimed_sha256: str) -> bool:
360
+ path = artifacts_dir / commit_key
361
+ if not path.is_file():
362
+ return False
363
+ return hashlib.sha256(path.read_bytes()).hexdigest() == claimed_sha256
364
+
365
+
366
+ def _safe_key(key: str) -> str:
367
+ """Artifact keys are relative paths under the artifacts dir — refuse
368
+ anything that could escape it."""
369
+ if key.startswith("/") or ".." in key.split("/"):
370
+ raise HTTPException(status_code=400, detail=f"invalid artifact key: {key}")
371
+ return key
372
+
373
+
374
+ def _bearer(request: Request) -> str | None:
375
+ header = request.headers.get("Authorization") or ""
376
+ scheme, _, token = header.partition(" ")
377
+ return token.strip() if scheme.lower() == "bearer" and token.strip() else None
378
+
379
+
380
+ def _is_operator(state, token: str | None) -> bool:
381
+ """Is this a driver credential (authenticated, attributable, unscoped)?
382
+
383
+ Read defensively: the cloud supplies its own authenticator (Plan 3), and
384
+ one written before this concept existed has no `is_operator`. A missing
385
+ or non-bool answer means "no operators here" — the confinement stays on.
386
+ """
387
+ checker = getattr(state.authenticator, "is_operator", None)
388
+ if checker is None:
389
+ return False
390
+ return checker(token) is True
391
+
392
+
393
+ def _authenticated_node(state, token: str | None) -> str:
394
+ """The caller's node_id, or 401. Callers must have checked `enforcing`."""
395
+ node_id = state.authenticator.authenticate(token)
396
+ if not isinstance(node_id, str) or not node_id:
397
+ raise HTTPException(status_code=401, detail="invalid or missing node token")
398
+ return node_id
399
+
400
+
401
+ #: An operator may name the machine it is forwarding for, on writes AND on
402
+ #: the lease lifecycle. Honoured ONLY from an operator credential, and read in
403
+ #: exactly one place — see `_write_identity`.
404
+ DELEGATION_HEADER = "X-FlashML-On-Behalf-Of"
405
+
406
+
407
+ def _delegated_node(request: Request) -> str | None:
408
+ """The node named by the delegation header, or None if absent.
409
+
410
+ Never call this for a non-operator caller: for anyone else the header is
411
+ not authoritative, so it must not be parsed either — a volunteer that
412
+ cannot be believed also cannot be allowed to turn its own writes into
413
+ 400s, and probing the delegation path should tell it nothing.
414
+ """
415
+ values = request.headers.getlist(DELEGATION_HEADER)
416
+ if not values:
417
+ return None
418
+ if len(values) > 1:
419
+ # Two values means somebody appended one. The API forwarding an
420
+ # agent's request would produce exactly that if it failed to strip
421
+ # the agent's copy, and Starlette would hand us the *first* — an
422
+ # ordering the agent controls. Refuse rather than pick a winner.
423
+ raise HTTPException(
424
+ status_code=400,
425
+ detail=f"ambiguous {DELEGATION_HEADER}: {len(values)} values",
426
+ )
427
+ # An empty value is deliberately returned as "" rather than None: it is a
428
+ # header that was sent, and `_write_identity` fails it closed. Folding it
429
+ # into None would let an API bug that emits a blank header silently
430
+ # restore unscoped operator reach.
431
+ return values[0].strip()
432
+
433
+
434
+ def _write_identity(state, request: Request) -> str | None:
435
+ """Which machine is this request acting as? A node_id, or None for an
436
+ unscoped operator that named nobody. 401 if the caller is neither.
437
+
438
+ THE single place delegation is decided — the only reader of
439
+ `DELEGATION_HEADER` in the repo. Every authorization surface funnels
440
+ through here: artifacts key writes by prefix, checkpoints key them by the
441
+ (job, task) pair, and the lease lifecycle keys them by lease holder. If
442
+ each resolved the caller for itself the three would drift, and the drift
443
+ would be a silent hole — this repo has been bitten by exactly that seam
444
+ twice. (The name is historical: it predates the lifecycle endpoints. It
445
+ resolves *identity*, not writes.)
446
+
447
+ Delegation can only ever *narrow*. An operator with no header keeps the
448
+ unscoped driver reach it has always had on writes; the moment it asserts
449
+ an identity it is authorized precisely as that node would have been, so a
450
+ driver speaking for node-a loses its own `jobs/{job}/round-NNN/` keys.
451
+ """
452
+ token = _bearer(request)
453
+ if _is_operator(state, token):
454
+ delegated = _delegated_node(request)
455
+ if delegated is None:
456
+ return None # unscoped driver — the pre-delegation behaviour
457
+ if not delegated:
458
+ raise HTTPException(
459
+ status_code=403, detail=f"empty {DELEGATION_HEADER}"
460
+ )
461
+ # NOT checked against the node registry. Liveness of a lease is the
462
+ # only authority that matters, and it is strictly stronger: a node
463
+ # can only hold one by having registered and claimed. Consulting the
464
+ # in-memory registry too would add a second source of truth about
465
+ # identity that a coordinator restart empties while durable leases
466
+ # survive — refusing writes the lease table still authorizes.
467
+ return delegated
468
+ # Any other caller: the header is ignored entirely. Not an error — it is
469
+ # simply not authoritative, and a volunteer must never act as another
470
+ # machine.
471
+ return _authenticated_node(state, token)
472
+
473
+
474
+ def _acting_node(state, request: Request) -> str:
475
+ """The machine this request acts as, where acting as *nobody* is not a
476
+ valid answer. Same resolution as `_write_identity` — same single header
477
+ reader — with the unscoped-operator case turned into a refusal.
478
+
479
+ The lease lifecycle (register / claim / heartbeat / complete / fail) is
480
+ always an act by a specific machine: a claim assigns work to a node, a
481
+ `fail` returns another node's task to the queue. An operator that names
482
+ nobody has no identity to check, and quietly letting it through would
483
+ hand the API the ability to drive *any* lease purely by omitting a
484
+ header — the exact requeue-steal hole `_require_lease_holder` closes,
485
+ re-opened for the credential that is easiest to forward wrongly.
486
+
487
+ A node token is unaffected: `_write_identity` never honours the header
488
+ for it, so it is always itself and can never reach another node's lease.
489
+ """
490
+ node_id = _write_identity(state, request)
491
+ if node_id is None:
492
+ # The pre-delegation answer, unchanged and deliberately so: an
493
+ # operator token is not a node identity, so it 401s here exactly as
494
+ # it did before this header existed (pinned by the write-scope
495
+ # suite). Naming a machine is what gives it one.
496
+ raise HTTPException(
497
+ status_code=401,
498
+ detail=(
499
+ "operator credential is not a node identity — name the "
500
+ f"machine in {DELEGATION_HEADER} to act for it"
501
+ ),
502
+ )
503
+ return node_id
504
+
505
+
506
+ def _authorize_write(state, manager, request: Request, key: str) -> None:
507
+ """Confine a write to the tasks this caller currently holds.
508
+
509
+ Not enforcing ⇒ allow: the self-hosted profile predates credentials and
510
+ must keep working (CLAUDE.md rule 4). When enforcing, an unknown caller is
511
+ 401 and an out-of-scope key is 403 — distinct so an operator can tell a
512
+ misconfigured agent from a misbehaving one.
513
+
514
+ An *operator* token passes unscoped: a driver (fedavg/K-means reducer)
515
+ runs inside the trusted API, holds no lease, and must still write
516
+ `jobs/{job}/round-NNN/weights.json`. That is a second credential class,
517
+ not an exemption — the caller is still authenticated and attributable.
518
+ An operator forwarding for a machine names it in `DELEGATION_HEADER` and
519
+ gets that machine's scope instead.
520
+ """
521
+ if not state.authenticator.enforcing:
522
+ return
523
+ node_id = _write_identity(state, request)
524
+ if node_id is None:
525
+ return
526
+ # Trailing slash is load-bearing: without it `jobs/j/trial-000extra/...`
527
+ # would satisfy a `jobs/j/trial-000` prefix test.
528
+ allowed = [f"jobs/{job}/{task}/" for job, task in manager.live_leases_for_node(node_id)]
529
+ if not any(key.startswith(p) for p in allowed):
530
+ raise HTTPException(
531
+ status_code=403,
532
+ detail=f"node {node_id} holds no live lease covering {key!r}",
533
+ )
534
+
535
+
536
+ def authorize_task_write(state, manager, request: Request, job_id: str, task_id: str) -> None:
537
+ """Checkpoint routes name (job, task) in the path, so authorize the pair
538
+ directly instead of reconstructing a key prefix.
539
+
540
+ Same caller resolution as `_authorize_write` — including delegation —
541
+ because it is the same question asked about a differently-shaped key.
542
+ """
543
+ if not state.authenticator.enforcing:
544
+ return
545
+ node_id = _write_identity(state, request)
546
+ if node_id is None:
547
+ return
548
+ if (job_id, task_id) not in manager.live_leases_for_node(node_id):
549
+ raise HTTPException(
550
+ status_code=403,
551
+ detail=f"node {node_id} holds no live lease on {job_id}/{task_id}",
552
+ )
553
+
554
+
555
+ def _require_lease_holder(state, manager, request: Request, lease_id: str) -> None:
556
+ """Only the node that was issued a lease may drive its lifecycle.
557
+
558
+ Without this, `complete`/`fail`/`heartbeat` take a lease_id and check
559
+ nothing: an attacker fails another node's attempt over and over until the
560
+ task requeues to *him*, then writes his poison entirely within the write
561
+ scoping above. Scoping writes without owning the lifecycle just moves the
562
+ hole down one layer.
563
+
564
+ A *bare* operator token is still NOT accepted here. A driver holds no
565
+ lease; letting an unscoped credential fail somebody's attempt would
566
+ re-open exactly the requeue attack this closes. An operator that names a
567
+ machine in `DELEGATION_HEADER` is a different thing: it is checked
568
+ against *that machine's* lease, so the forwarded call is authorized
569
+ exactly as the direct call would have been and no more.
570
+ """
571
+ if not state.authenticator.enforcing:
572
+ return
573
+ node_id = _acting_node(state, request)
574
+ lease = manager.lease_info(lease_id)
575
+ if lease is None or getattr(lease, "node_id", None) != node_id:
576
+ # 403 for unknown as well as not-yours: a 404/403 split would tell an
577
+ # attacker which lease ids exist.
578
+ raise HTTPException(
579
+ status_code=403, detail=f"node {node_id} does not hold lease {lease_id}"
580
+ )
581
+
582
+
583
+ def build_router(state: ModeAState) -> APIRouter:
584
+ router = APIRouter(prefix="/v1alpha1")
585
+ manager = state.manager
586
+
587
+ # -- node registry ------------------------------------------------------
588
+
589
+ @router.post("/nodes/register")
590
+ async def register_node(reg: NodeRegistration, request: Request):
591
+ if state.join_code is not None:
592
+ supplied = request.headers.get("X-FlashML-Join-Code")
593
+ if supplied != state.join_code:
594
+ raise HTTPException(status_code=403, detail="invalid or missing join code")
595
+ if state.authenticator.enforcing:
596
+ # Identity comes from the credential (or, for an operator, from
597
+ # the machine it names) — never from the body. A node must not be
598
+ # able to register (and so claim work) as another.
599
+ reg = reg.model_copy(update={"node_id": _acting_node(state, request)})
600
+ state.nodes[reg.node_id] = _NodeEntry(
601
+ registration=reg, last_heartbeat=datetime.now(timezone.utc)
602
+ )
603
+ return {"node_id": reg.node_id, "status": "registered"}
604
+
605
+ @router.post("/nodes/{node_id}/heartbeat")
606
+ async def node_heartbeat(node_id: str, hb: NodeHeartbeat, request: Request):
607
+ if state.authenticator.enforcing:
608
+ caller = _acting_node(state, request)
609
+ if caller != node_id:
610
+ # "itself" means the asserted machine when an operator is
611
+ # forwarding — a heartbeat keeps a node marked online, so
612
+ # letting the API send one for an arbitrary node_id would
613
+ # falsify the pool view.
614
+ raise HTTPException(
615
+ status_code=403, detail="a node may only heartbeat itself"
616
+ )
617
+ entry = state.nodes.get(node_id)
618
+ if entry is None:
619
+ raise HTTPException(status_code=404, detail=f"unknown node {node_id} — register first")
620
+ entry.last_heartbeat = hb.timestamp
621
+ return {"status": "ok"}
622
+
623
+ @router.get("/nodes")
624
+ async def list_nodes():
625
+ return state.node_view()
626
+
627
+ # -- leases -------------------------------------------------------------
628
+
629
+ @router.post("/leases/claim")
630
+ async def claim(req: ClaimRequest, request: Request):
631
+ if state.authenticator.enforcing:
632
+ # Overwrite rather than validate-and-reject: a body node_id is
633
+ # simply not authoritative, so a disagreement is not an error to
634
+ # report — there is nothing here to disagree with. That holds for
635
+ # the forwarded case too: the operator's header decides, the body
636
+ # is still ignored.
637
+ req.node_id = _acting_node(state, request)
638
+ entry = state.nodes.get(req.node_id)
639
+ if entry is None:
640
+ raise HTTPException(status_code=403, detail="unregistered node — register first")
641
+ node_view = {
642
+ "node_id": req.node_id,
643
+ "sandbox_capable": entry.registration.sandbox_capable,
644
+ "argv_capable": entry.registration.argv_capable,
645
+ "module_capable": entry.registration.module_capable,
646
+ "capabilities": entry.registration.capabilities.model_dump(),
647
+ }
648
+ lease = manager.claim(
649
+ req.node_id,
650
+ job_id=req.job_id,
651
+ policy=IsolationAwarePlacement(),
652
+ node=node_view,
653
+ )
654
+ if lease is None:
655
+ return Response(status_code=204) # nothing claimable right now
656
+ return lease
657
+
658
+ @router.post("/attempts/{lease_id}/heartbeat")
659
+ async def attempt_heartbeat(lease_id: str, request: Request):
660
+ from flashruntime.leases import LeaseError
661
+
662
+ # Same ownership rule as complete/fail: keeping somebody else's lease
663
+ # alive stops the sweeper from ever requeueing their stalled task.
664
+ _require_lease_holder(state, manager, request, lease_id)
665
+ try:
666
+ return manager.heartbeat(lease_id)
667
+ except LeaseError as exc:
668
+ # 410 Gone: the worker must stop — its lease is dead.
669
+ raise HTTPException(status_code=410, detail=str(exc))
670
+
671
+ @router.post("/attempts/{lease_id}/complete")
672
+ async def attempt_complete(lease_id: str, req: CompleteRequest, request: Request):
673
+ from flashruntime.leases import LeaseError
674
+
675
+ _require_lease_holder(state, manager, request, lease_id)
676
+ lease = manager.lease_info(lease_id)
677
+ if lease is None:
678
+ raise HTTPException(status_code=404, detail=f"unknown lease {lease_id}")
679
+
680
+ # Accepted work = validated output: the artifact at the task's
681
+ # commit_key must exist and hash to what the worker claims. A bad
682
+ # upload fails the attempt (task requeues elsewhere); it never
683
+ # commits. Fault tolerance that accepts wrong results is worse than
684
+ # failure.
685
+ record = next(
686
+ (r for r in manager.records(lease.job_id) if r.spec.task_id == lease.task_id), None
687
+ )
688
+ if record is not None and not _output_valid(
689
+ state.artifacts_dir, record.spec.commit_key, req.output_sha256
690
+ ):
691
+ try:
692
+ manager.fail(
693
+ lease_id, f"output validation failed for {record.spec.commit_key}"
694
+ )
695
+ return {"accepted": False, "detail": "output validation failed; attempt requeued"}
696
+ except LeaseError:
697
+ pass # lease already dead → fall through to the late-commit rejection
698
+
699
+ try:
700
+ accepted = manager.complete(lease_id, output_sha256=req.output_sha256)
701
+ except LeaseError as exc:
702
+ raise HTTPException(status_code=404, detail=str(exc))
703
+ if accepted and lease.node_id in state.nodes:
704
+ # credit accepted work only — the contribution-accounting rule
705
+ state.nodes[lease.node_id].accepted_tasks += 1
706
+ return {"accepted": accepted}
707
+
708
+ @router.post("/attempts/{lease_id}/fail")
709
+ async def attempt_fail(lease_id: str, req: FailRequest, request: Request):
710
+ from flashruntime.leases import LeaseError
711
+
712
+ _require_lease_holder(state, manager, request, lease_id)
713
+ try:
714
+ manager.fail(lease_id, req.reason)
715
+ except LeaseError as exc:
716
+ raise HTTPException(status_code=410, detail=str(exc))
717
+ return {"status": "requeued-or-exhausted"}
718
+
719
+ # -- tasks view ---------------------------------------------------------
720
+
721
+ @router.get("/jobs/{job_id}/tasks")
722
+ async def job_tasks(job_id: str):
723
+ out = []
724
+ for r in manager.records(job_id):
725
+ lease = r.active_lease
726
+ last = None
727
+ if r.lease_history:
728
+ last = list(r.lease_history.values())[-1]
729
+ out.append(
730
+ {
731
+ "task_id": r.spec.task_id,
732
+ "state": r.state.value,
733
+ "attempts": r.attempts_used,
734
+ "max_attempts": r.spec.max_attempts,
735
+ "node_id": (lease or last).node_id if (lease or last) else None,
736
+ "deadline": lease.deadline.isoformat() if lease else None,
737
+ }
738
+ )
739
+ return sorted(out, key=lambda t: t["task_id"])
740
+
741
+ # -- local artifact hosting --------------------------------------------
742
+
743
+ @router.put("/artifacts/{key:path}")
744
+ async def put_artifact(key: str, request: Request):
745
+ key = _safe_key(key)
746
+ # Checked TWICE, on purpose. The first call rejects an unauthorized
747
+ # caller before we buffer a body that may be hundreds of megabytes.
748
+ _authorize_write(state, manager, request, key)
749
+ data = await request.body()
750
+ if len(data) > state.max_artifact_bytes:
751
+ raise HTTPException(
752
+ status_code=413,
753
+ detail=f"artifact exceeds {state.max_artifact_bytes} bytes",
754
+ )
755
+ # ...and the second closes the TOCTOU window between them. Reading the
756
+ # body takes as long as the client wants it to: a node can claim a
757
+ # task, open a chunked PUT, trickle bytes while the sweeper expires
758
+ # its lease and another node completes and commits the task — and
759
+ # then land its body on top of the committed result. That window is
760
+ # attacker-controlled, not bounded by the lease duration, and it
761
+ # defeats revocation entirely. `live_leases_for_node` stops covering
762
+ # a completed or reclaimed task, so re-checking here is the fix.
763
+ _authorize_write(state, manager, request, key)
764
+ path = state.artifacts_dir / key
765
+ path.parent.mkdir(parents=True, exist_ok=True)
766
+ path.write_bytes(data)
767
+ return ArtifactRecord(
768
+ uri=f"artifact://{key}",
769
+ backend="local",
770
+ bucket=str(state.artifacts_dir),
771
+ object_key=key,
772
+ sha256=hashlib.sha256(data).hexdigest(),
773
+ size_bytes=len(data),
774
+ )
775
+
776
+ @router.get("/artifacts/{key:path}")
777
+ async def get_artifact(key: str):
778
+ key = _safe_key(key)
779
+ path = state.artifacts_dir / key
780
+ if not path.is_file():
781
+ raise HTTPException(status_code=404, detail=f"no artifact at {key}")
782
+ return Response(content=path.read_bytes(), media_type="application/octet-stream")
783
+
784
+ @router.get("/jobs/{job_id}/artifacts")
785
+ async def job_artifacts(job_id: str):
786
+ base = state.artifacts_dir / "jobs" / job_id
787
+ if not base.is_dir():
788
+ return []
789
+ out = []
790
+ for path in sorted(base.rglob("*")):
791
+ if path.is_file():
792
+ key = str(path.relative_to(state.artifacts_dir))
793
+ out.append(
794
+ {
795
+ "uri": f"artifact://{key}",
796
+ "key": key,
797
+ "size_bytes": path.stat().st_size,
798
+ }
799
+ )
800
+ return out
801
+
802
+ return router
803
+
804
+
805
+ def lease_job_state(manager: LeaseManager, job_id: str) -> tuple[str, dict[str, int]]:
806
+ """Derive a JobState name from the task counts (status is never a
807
+ hand-mutated field — it falls out of the lease table)."""
808
+ counts = manager.job_state(job_id)
809
+ total = sum(counts.values())
810
+ done = counts.get(TaskState.COMPLETED.value, 0)
811
+ failed = counts.get(TaskState.FAILED.value, 0)
812
+ active = counts.get(TaskState.PENDING.value, 0) + counts.get(TaskState.LEASED.value, 0)
813
+ if total == 0:
814
+ return "PENDING", counts
815
+ if active > 0:
816
+ return "RUNNING", counts
817
+ if failed > 0:
818
+ return "FAILED", counts
819
+ if done == total:
820
+ return "SUCCEEDED", counts
821
+ return "CANCELLED", counts