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,134 @@
1
+ """RunViewerServer — the read-only HTTP surface over one run directory.
2
+
3
+ A stdlib `ThreadingHTTPServer` a `flash.submit(watch=True)` opens on the run's
4
+ output_dir. It serves exactly four things and 404s everything else:
5
+
6
+ GET / → the live run page (viewer.page.render(); polls /api/state)
7
+ GET /api/state → state.collect(run_dir) as JSON (the live snapshot)
8
+ GET /docs, /docs/…→ static files under the packaged _docs/ dir, else 404
9
+ (anything else) → 404
10
+
11
+ Threading, not a single-thread loop: the page polls /api/state every couple
12
+ of seconds while a human also clicks around; a blocking handler would stall
13
+ one request behind another. Everything is bound to 127.0.0.1 — this is a
14
+ personal, local viewer, never a network service.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import mimetypes
21
+ import threading
22
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
23
+ from pathlib import Path
24
+ from urllib.parse import urlparse
25
+
26
+ from flashruntime.viewer.page import render as render_page
27
+ from flashruntime.viewer.state import collect
28
+
29
+ # Packaged docs live here once Task 8's builder has run; absent in a fresh
30
+ # checkout, which is why /docs degrades to an honest "docs not built" 404.
31
+ _DEFAULT_DOCS_DIR = Path(__file__).parent / "_docs"
32
+
33
+ # The run page is a static document (all liveness comes from its /api/state
34
+ # polling), so render it once and reuse the bytes for every GET /.
35
+ _PAGE_BYTES = render_page().encode()
36
+
37
+
38
+ class _Handler(BaseHTTPRequestHandler):
39
+ """Routes GETs for one run. Reads its run_dir/docs_dir off the server
40
+ instance (`self.server`), so a single handler class serves any run."""
41
+
42
+ # Silence the default per-request stderr line: the user launched this from
43
+ # the same terminal their training logs to, and access-log spam would bury
44
+ # that output. (The viewer is deliberately quiet.)
45
+ def log_message(self, *args) -> None: # noqa: D401
46
+ pass
47
+
48
+ def do_GET(self) -> None:
49
+ path = urlparse(self.path).path
50
+ if path == "/":
51
+ self._send(200, "text/html; charset=utf-8", _PAGE_BYTES)
52
+ elif path == "/api/state":
53
+ # collect() is total (never raises), so this branch cannot 500 on
54
+ # any on-disk state — it returns a snapshot or an {"error": ...}.
55
+ body = json.dumps(self.server.run_state()).encode()
56
+ self._send(200, "application/json; charset=utf-8", body)
57
+ elif path == "/docs" or path.startswith("/docs/"):
58
+ self._serve_docs(path)
59
+ else:
60
+ self._send(404, "text/plain; charset=utf-8", b"not found")
61
+
62
+ def _serve_docs(self, path: str) -> None:
63
+ docs_dir: Path = self.server.docs_dir
64
+ if not docs_dir.is_dir():
65
+ # The builder (Task 8) has not run in this checkout.
66
+ self._send(404, "text/plain; charset=utf-8", b"docs not built")
67
+ return
68
+ rel = path[len("/docs"):].lstrip("/") or "index.html" # /docs and /docs/ → index.html
69
+ target = (docs_dir / rel).resolve()
70
+ # PATH-TRAVERSAL GUARD: `rel` comes straight from the request line, so
71
+ # a crafted "/docs/../../etc/passwd" would otherwise resolve OUTSIDE
72
+ # docs_dir. Reject anything whose resolved path is not under the docs
73
+ # root — serving files by request path without this check is a hole.
74
+ if not target.is_relative_to(docs_dir.resolve()) or not target.is_file():
75
+ self._send(404, "text/plain; charset=utf-8", b"not found")
76
+ return
77
+ ctype = mimetypes.guess_type(str(target))[0] or "application/octet-stream"
78
+ try:
79
+ body = target.read_bytes()
80
+ except OSError:
81
+ self._send(404, "text/plain; charset=utf-8", b"not found")
82
+ return
83
+ self._send(200, ctype, body)
84
+
85
+ def _send(self, code: int, ctype: str, body: bytes) -> None:
86
+ self.send_response(code)
87
+ self.send_header("Content-Type", ctype)
88
+ self.send_header("Content-Length", str(len(body)))
89
+ self.end_headers()
90
+ self.wfile.write(body)
91
+
92
+
93
+ class RunViewerServer:
94
+ """Serve one run directory over HTTP on 127.0.0.1.
95
+
96
+ `port=0` (the default) asks the OS for a free port, so opening a viewer
97
+ never collides with another run's. `.start()` binds, spawns a daemon
98
+ serve loop, and returns the URL; `.stop()` shuts the loop down and closes
99
+ the socket (frees the port). `docs_dir` defaults to the packaged _docs/
100
+ but is injectable — the traversal guard and the docs routes are only
101
+ testable against a real directory, and the package's own _docs/ does not
102
+ exist until Task 8 builds it.
103
+ """
104
+
105
+ def __init__(self, run_dir: Path, port: int = 0, docs_dir: Path | None = None):
106
+ self.run_dir = Path(run_dir)
107
+ self.docs_dir = Path(docs_dir) if docs_dir is not None else _DEFAULT_DOCS_DIR
108
+ self._requested_port = port
109
+ self._httpd: ThreadingHTTPServer | None = None
110
+ self._thread: threading.Thread | None = None
111
+ self.url: str | None = None
112
+
113
+ def start(self) -> str:
114
+ httpd = ThreadingHTTPServer(("127.0.0.1", self._requested_port), _Handler)
115
+ # Hand the handler its per-server config. run_state is a bound method
116
+ # so each request re-reads the run dir fresh (a live snapshot).
117
+ httpd.run_state = lambda: collect(self.run_dir) # type: ignore[attr-defined]
118
+ httpd.docs_dir = self.docs_dir # type: ignore[attr-defined]
119
+ httpd.daemon_threads = True # worker threads never block interpreter exit
120
+ self._httpd = httpd
121
+ port = httpd.server_address[1] # the OS-assigned port when we asked for 0
122
+ self._thread = threading.Thread(target=httpd.serve_forever, daemon=True)
123
+ self._thread.start()
124
+ self.url = f"http://127.0.0.1:{port}"
125
+ return self.url
126
+
127
+ def stop(self) -> None:
128
+ if self._httpd is not None:
129
+ self._httpd.shutdown() # stop serve_forever
130
+ self._httpd.server_close() # close the listening socket → port freed
131
+ self._httpd = None
132
+ if self._thread is not None:
133
+ self._thread.join(timeout=2)
134
+ self._thread = None
@@ -0,0 +1,250 @@
1
+ """state.collect(run_dir) — assemble the /api/state snapshot from disk.
2
+
3
+ WHY EXCEPTION-SAFETY IS THE WHOLE POINT: this reads a *live* run's directory
4
+ while the SDK is still writing into it. metrics.jsonl grows an unterminated
5
+ last line between our read and the writer's flush; a manifest may be
6
+ half-written; an attempt's output_dir may not exist yet; a file may be
7
+ unreadable. A viewer that raised on any of these would turn "someone glanced
8
+ at the run" into "the run's dashboard 500'd" — and worse, invite the reflex
9
+ to make the SDK slow down or lock for the reader. So collect() is READ-ONLY
10
+ and TOTAL: every disk hazard degrades to a partial snapshot (an empty
11
+ section, a skipped record, an `"error"` string) and NEVER an exception. The
12
+ run's story is never interrupted by being watched.
13
+
14
+ Data model: pass the parsed `viewer_v1` run.json through verbatim, then
15
+ ENRICH it — each attempt gains its own `metrics` (metrics.jsonl tail) and
16
+ `log_tail` (launcher.log tail), `telemetry` (sampler tail) and `ranks`
17
+ (per-rank heartbeats), read from that attempt's `output_dir`, and a
18
+ top-level `checkpoints` list is assembled from the job ckpt roots (each root
19
+ is the sibling `ckpt/` dir of an attempt dir). A top-level `monitor` object
20
+ carries the newest telemetry sample (machine stats + process tree).
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import io
26
+ import json
27
+ import time
28
+ from pathlib import Path
29
+
30
+ _METRICS_TAIL = 500 # last N points per attempt — enough for a loss curve, bounded memory
31
+ _LOG_TAIL = 100 # last N lines of launcher.log — a glance at what the process last said
32
+ _TELEMETRY_TAIL = 150 # last N sampler ticks — a ~5 min usage chart at 2 s period
33
+
34
+ # The tail window we seek-and-read from the END of a growing log. WHY BOUNDED:
35
+ # the viewer re-reads these files every ~2 s against a LIVE run; a chatty
36
+ # trainer's launcher.log / metrics.jsonl can reach multiple GB, and slurping
37
+ # the whole file each poll would blow memory and thrash the disk against the
38
+ # writer — the very interference this module promises not to cause. 256 KiB
39
+ # comfortably holds the last 500 metrics records (~0.5 KiB each) or 100 log
40
+ # lines of any sane width, so the wanted tail always lands inside one window.
41
+ _TAIL_WINDOW = 256 * 1024
42
+
43
+
44
+ def collect(run_dir: Path) -> dict:
45
+ """Read `<run_dir>/run.json` (the viewer_v1 contract) and return the
46
+ enriched /api/state dict. Never raises: an outer backstop converts any
47
+ unforeseen failure into `{"error": ...}` so a viewer can never crash a
48
+ live run — see the module docstring."""
49
+ try:
50
+ return _collect(Path(run_dir))
51
+ except Exception as exc: # noqa: BLE001 — total by contract; see module docstring
52
+ return {"error": f"viewer snapshot failed: {exc!r}"}
53
+
54
+
55
+ def _collect(run_dir: Path) -> dict:
56
+ run_json = run_dir / "run.json"
57
+ try:
58
+ raw = run_json.read_text(errors="replace")
59
+ except OSError as exc:
60
+ return {"error": f"run.json not found: {exc}"}
61
+ try:
62
+ doc = json.loads(raw)
63
+ except ValueError as exc:
64
+ # A torn read (writer mid-os.replace is atomic, but a truncated file
65
+ # from any other cause still lands here) yields an error, not a crash.
66
+ return {"error": f"run.json unreadable (torn or invalid JSON): {exc}"}
67
+ contract = doc.get("contract") if isinstance(doc, dict) else None
68
+ if contract != "viewer_v1":
69
+ # Fail closed on an unknown contract: a future/foreign schema is not
70
+ # something this version can honestly render, so say so rather than
71
+ # guess at fields that may have moved.
72
+ return {"error": f"unknown run.json contract: {contract!r}"}
73
+
74
+ # Enrich each attempt with its own metrics + log tail (both live in the
75
+ # attempt's output_dir), and collect the distinct job ckpt roots as we go.
76
+ ckpt_roots: dict[str, Path] = {}
77
+ enriched: list[dict] = []
78
+ for attempt in doc.get("attempts") or []:
79
+ row = dict(attempt)
80
+ out = row.get("output_dir")
81
+ attempt_dir = Path(out) if out else None
82
+ row["metrics"] = _metrics_tail(attempt_dir) if attempt_dir else []
83
+ row["log_tail"] = _log_tail(attempt_dir) if attempt_dir else ""
84
+ row["telemetry"] = _telemetry_tail(attempt_dir) if attempt_dir else []
85
+ row["ranks"] = _ranks(attempt_dir) if attempt_dir else []
86
+ enriched.append(row)
87
+ if attempt_dir is not None:
88
+ # The job ckpt root is the sibling `ckpt/` dir of the attempt dir
89
+ # (`<run>/<job>/<attempt>` → `<run>/<job>/ckpt`); restarts share a
90
+ # job_id, so many attempts map to one root — dedupe by path.
91
+ root = attempt_dir.parent / "ckpt"
92
+ ckpt_roots[str(root)] = root
93
+ doc["attempts"] = enriched
94
+
95
+ manifests: list[dict] = []
96
+ for root in ckpt_roots.values():
97
+ try:
98
+ manifests.extend(_manifests_for_root(root))
99
+ except Exception: # noqa: BLE001 — one bad root never drops the others
100
+ continue
101
+ manifests.sort(key=lambda m: (m.get("job_id", ""), m.get("step", 0)))
102
+ doc["checkpoints"] = manifests
103
+
104
+ # The newest telemetry sample anywhere becomes the machine-level `monitor`
105
+ # object (KPI tiles + machine node read it). Newest-by-ts, not "the
106
+ # running attempt's", so a just-finished run still shows its last stats.
107
+ monitor = None
108
+ for row in enriched:
109
+ tel = row.get("telemetry") or []
110
+ cand = tel[-1] if tel else None
111
+ if isinstance(cand, dict) and (monitor is None or cand.get("ts", 0) > monitor.get("ts", 0)):
112
+ monitor = cand
113
+ doc["monitor"] = monitor
114
+ return doc
115
+
116
+
117
+ def _tail_window_lines(path: Path) -> list[str]:
118
+ """Return the whole lines contained in the last `_TAIL_WINDOW` bytes of
119
+ `path` — a bounded seek+read that NEVER loads the whole file (see the
120
+ `_TAIL_WINDOW` note). Total: any OSError (missing / dir gone / unreadable)
121
+ degrades to `[]`, never an exception."""
122
+ try:
123
+ with path.open("rb") as fh:
124
+ fh.seek(0, io.SEEK_END)
125
+ size = fh.tell()
126
+ # Read a window ending at EOF rather than from byte 0: on a
127
+ # multi-GB log this is one bounded read, not a full slurp. When the
128
+ # file is smaller than the window, start clamps to 0 (whole file).
129
+ start = max(0, size - _TAIL_WINDOW)
130
+ fh.seek(start)
131
+ window = fh.read()
132
+ except OSError:
133
+ return []
134
+ lines = window.decode("utf-8", errors="replace").splitlines()
135
+ # If we began mid-file (start > 0), the first "line" is a torn fragment of
136
+ # the record straddling the window's start byte — drop it so only whole
137
+ # lines escape (a partial JSON metrics line would skip on parse anyway).
138
+ if start > 0 and lines:
139
+ lines = lines[1:]
140
+ return lines
141
+
142
+
143
+ def _metrics_tail(attempt_dir: Path, limit: int = _METRICS_TAIL) -> list[dict]:
144
+ """Last `limit` JSON records from `metrics.jsonl`, keys passed through
145
+ as-is. A half-written final line (the writer appended between our read and
146
+ its newline) fails to parse and is skipped — the earlier points survive.
147
+ Reads only the bounded tail window, never the whole file."""
148
+ records: list[dict] = []
149
+ for line in _tail_window_lines(attempt_dir / "metrics.jsonl"):
150
+ line = line.strip()
151
+ if not line:
152
+ continue
153
+ try:
154
+ rec = json.loads(line)
155
+ except ValueError:
156
+ continue # torn/partial line — skip it, keep the rest
157
+ if isinstance(rec, dict):
158
+ records.append(rec)
159
+ return records[-limit:]
160
+
161
+
162
+ def _telemetry_tail(attempt_dir: Path, limit: int = _TELEMETRY_TAIL) -> list[dict]:
163
+ """Last `limit` sampler ticks from `telemetry.jsonl` — the same bounded
164
+ tail-window + skip-torn-lines discipline as `_metrics_tail` (the sampler
165
+ appends live while we read)."""
166
+ records: list[dict] = []
167
+ for line in _tail_window_lines(attempt_dir / "telemetry.jsonl"):
168
+ line = line.strip()
169
+ if not line:
170
+ continue
171
+ try:
172
+ rec = json.loads(line)
173
+ except ValueError:
174
+ continue # torn/partial line — skip it, keep the rest
175
+ if isinstance(rec, dict):
176
+ records.append(rec)
177
+ return records[-limit:]
178
+
179
+
180
+ def _ranks(attempt_dir: Path) -> list[dict]:
181
+ """Every parseable `ranks/rank-*.json` heartbeat, sorted by rank. A torn
182
+ file (a rank mid-os.replace cannot tear, but a foreign writer could) is
183
+ skipped; a missing dir is simply 'not instrumented' — []."""
184
+ try:
185
+ paths = sorted((attempt_dir / "ranks").glob("rank-*.json"))
186
+ except OSError:
187
+ return []
188
+ out: list[dict] = []
189
+ for p in paths:
190
+ try:
191
+ rec = json.loads(p.read_text())
192
+ except (OSError, ValueError):
193
+ continue
194
+ if isinstance(rec, dict):
195
+ out.append(rec)
196
+ out.sort(key=lambda r: r.get("rank", 0) if isinstance(r.get("rank", 0), int) else 0)
197
+ return out
198
+
199
+
200
+ def _log_tail(attempt_dir: Path, tail_lines: int = _LOG_TAIL) -> str:
201
+ """Last `tail_lines` lines of `launcher.log`, or "" if unreadable. Reads
202
+ only the bounded tail window, never the whole file."""
203
+ return "\n".join(_tail_window_lines(attempt_dir / "launcher.log")[-tail_lines:])
204
+
205
+
206
+ def _manifests_for_root(ckpt_root: Path) -> list[dict]:
207
+ """List every `step-*/manifest.json` under one job ckpt root, each
208
+ re-verified against disk. Reuses `flashruntime.checkpoint.local`'s
209
+ hashing (`verify_manifest`) and its picker (`latest_valid_manifest`) so
210
+ the "is this checkpoint safe" logic is never duplicated here — the viewer
211
+ only *reads* checkpoints, it must agree exactly with what recovery would
212
+ restore. Each entry: step, re-verified validation, part count, age, and
213
+ whether it is the one recovery would pick (`latest_valid`)."""
214
+ from flashruntime.checkpoint.local import (
215
+ MANIFEST_NAME,
216
+ latest_valid_manifest,
217
+ verify_manifest,
218
+ )
219
+ from flashruntime.protocol.v1alpha1 import CheckpointManifest
220
+
221
+ ckpt_root = Path(ckpt_root)
222
+ if not ckpt_root.is_dir():
223
+ return []
224
+ latest = latest_valid_manifest(ckpt_root) # the manifest recovery would restore
225
+ latest_step = latest.step if latest else None
226
+ out: list[dict] = []
227
+ for mf_path in sorted(ckpt_root.glob(f"step-*/{MANIFEST_NAME}")):
228
+ try:
229
+ manifest = CheckpointManifest.model_validate_json(mf_path.read_text())
230
+ except (OSError, ValueError):
231
+ continue # torn/invalid manifest — skip, keep scanning (never crash)
232
+ valid = verify_manifest(manifest, mf_path.parent)
233
+ try:
234
+ age_s: float | None = time.time() - manifest.created.timestamp()
235
+ except Exception: # noqa: BLE001 — a weird created value must not crash the scan
236
+ age_s = None
237
+ out.append(
238
+ {
239
+ "job_id": manifest.job_id,
240
+ "step": manifest.step,
241
+ # Report the RE-VERIFIED state, not the manifest's stored claim:
242
+ # a part corrupted after writing makes a "hash_verified" manifest
243
+ # actually invalid, and the viewer must show that truth.
244
+ "validation": "hash_verified" if valid else "invalid",
245
+ "parts": len(manifest.parts),
246
+ "age_s": age_s,
247
+ "latest_valid": bool(valid and manifest.step == latest_step),
248
+ }
249
+ )
250
+ return out
@@ -0,0 +1,6 @@
1
+ """Workload descriptions: WHAT the user wants run (four-axes rule: this is
2
+ the axis-zero input the recipes/strategies/launchers axes consume)."""
3
+
4
+ from flashruntime.workloads.command import CommandWorkload, OutputSpec, Source, to_jobspec
5
+
6
+ __all__ = ["CommandWorkload", "OutputSpec", "Source", "to_jobspec"]
@@ -0,0 +1,127 @@
1
+ """User-facing description of a "bring your own code" workload.
2
+
3
+ A CommandWorkload names WHAT to run (a command in a source directory,
4
+ optionally in a pinned image) and what FlashRuntime should do around it
5
+ (inputs, outputs, isolation, Mode A fan-out). It never describes HOW
6
+ distributed math happens — that belongs to the user's code and its
7
+ framework (ADR-0003: FlashRuntime operates jobs, it does not train).
8
+
9
+ Pydantic-only: importing this module must never require torch, sklearn,
10
+ kubernetes, or fastapi — it is part of the clean core.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import shlex
16
+ from typing import Literal
17
+
18
+ from pydantic import BaseModel, Field, field_validator
19
+
20
+ from flashruntime.protocol.plan_v1alpha1 import CheckpointPolicy
21
+ from flashruntime.protocol.v1alpha1 import (
22
+ ExecutionSpec,
23
+ ImageSpec,
24
+ IsolationSpec,
25
+ JobMetadata,
26
+ JobSpec,
27
+ JobSpecInner,
28
+ WorkloadSpec,
29
+ )
30
+ from flashruntime.providers import Requirements
31
+
32
+
33
+ class Source(BaseModel):
34
+ """Where the user's code lives. v1 executes from a local directory;
35
+ `git_revision` is reserved for remote packaging (spec §10 follow-up)."""
36
+
37
+ path: str = "."
38
+ git_revision: str | None = None
39
+
40
+
41
+ class OutputSpec(BaseModel):
42
+ """What to keep after a run. `collect` globs are resolved against the
43
+ script's working directory; `primary_metric` names the metrics.json key
44
+ Run.best_trial() ranks by."""
45
+
46
+ prefix: str = "artifact://jobs/{job_id}/"
47
+ collect: list[str] = Field(default_factory=lambda: ["metrics.json"])
48
+ primary_metric: str | None = None
49
+ maximize: bool = True
50
+
51
+
52
+ class CommandWorkload(BaseModel):
53
+ """One command, operated by FlashRuntime.
54
+
55
+ `command` may be a shell-style string (shlex-split, never shell=True —
56
+ pipes need an explicit `bash -c "..."`) or an argv list. `{name}`
57
+ placeholders are filled per `task_params` entry for Mode A fan-out.
58
+ """
59
+
60
+ command: str | list[str]
61
+ source: Source = Field(default_factory=Source)
62
+ image: ImageSpec | None = None
63
+ env: dict[str, str] = Field(default_factory=dict)
64
+ inputs: dict[str, str] = Field(default_factory=dict)
65
+ outputs: OutputSpec = Field(default_factory=OutputSpec)
66
+ resources: Requirements = Field(default_factory=Requirements)
67
+ isolation: IsolationSpec = Field(default_factory=IsolationSpec)
68
+ mode: Literal["auto", "local", "independent_tasks", "coordinated"] = "auto"
69
+ checkpoint: CheckpointPolicy | None = None
70
+ task_params: list[dict] | None = None
71
+
72
+ @field_validator("inputs")
73
+ @classmethod
74
+ def _artifact_scheme(cls, v: dict[str, str]) -> dict[str, str]:
75
+ for name, uri in v.items():
76
+ if not str(uri).startswith("artifact://"):
77
+ raise ValueError(f"input '{name}' must be an artifact:// URI, got {uri!r}")
78
+ return v
79
+
80
+ def argv(self, params: dict | None = None) -> list[str]:
81
+ """Exec-ready argv. `params` fills `{name}` placeholders; a
82
+ placeholder with no matching param raises KeyError (a silent empty
83
+ substitution would corrupt the command)."""
84
+ tokens = shlex.split(self.command) if isinstance(self.command, str) else list(self.command)
85
+ if params:
86
+ tokens = [t.format(**params) for t in tokens]
87
+ return tokens
88
+
89
+ def resolved_mode(self) -> str:
90
+ """Deterministic `auto` resolution (spec §4.1): fan-out params ⇒
91
+ independent_tasks; a multi-process launcher command ⇒ coordinated;
92
+ else local. An explicit `mode` always wins."""
93
+ if self.mode != "auto":
94
+ return self.mode
95
+ if self.task_params:
96
+ return "independent_tasks"
97
+ tokens = self.argv()
98
+ if tokens and tokens[0] in ("torchrun", "accelerate"):
99
+ return "coordinated"
100
+ return "local"
101
+
102
+
103
+ def to_jobspec(workload: CommandWorkload, name: str, image: ImageSpec | None = None) -> JobSpec:
104
+ """Wire form for the coordinator: JobSpec{execution.backend: leases,
105
+ workload.type: "command"}. A pinned image is required — remote runs
106
+ must be reproducible (the schema already rejects 'latest')."""
107
+ img = image or workload.image
108
+ if img is None:
109
+ raise ValueError("a pinned image is required to submit a command workload to the service")
110
+ parameters: dict = {
111
+ "command": workload.argv(), # normalized argv, placeholders intact
112
+ "env": dict(workload.env),
113
+ "inputs": dict(workload.inputs),
114
+ }
115
+ if workload.task_params is not None:
116
+ parameters["task_params"] = workload.task_params
117
+ if workload.checkpoint is not None:
118
+ parameters["checkpoint"] = workload.checkpoint.model_dump()
119
+ return JobSpec(
120
+ metadata=JobMetadata(name=name),
121
+ spec=JobSpecInner(
122
+ execution=ExecutionSpec(backend="leases"),
123
+ image=img,
124
+ workload=WorkloadSpec(type="command", parameters=parameters),
125
+ isolation=workload.isolation,
126
+ ),
127
+ )