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,167 @@
1
+ """Thin CLI over the FlashRuntime API — plus the offline planner and the
2
+ local "bring your own code" front door.
3
+
4
+ flashruntime plan path/to/plan.yaml [--json] # no API/cluster needed
5
+ flashruntime submit "python train.py" [--source DIR] [--task-params JSON] \
6
+ [--max-restarts N] [--output-dir DIR] [--watch|--no-watch] # local, no API
7
+ flashruntime submit-spec path/to/job.yaml [--api URL] # POST a JobSpec to the coordinator
8
+ flashruntime status <job-id>
9
+ flashruntime events <job-id>
10
+ flashruntime logs <job-id>
11
+ flashruntime cancel <job-id>
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+ import json
18
+ import os
19
+ import sys
20
+
21
+
22
+ def _api(args) -> str:
23
+ return args.api or os.environ.get("FLASHML_RUNTIME_API", "http://localhost:8100")
24
+
25
+
26
+ def _plan(args) -> int:
27
+ """Run the offline strategy planner: file → PlanRequest → PlanReport."""
28
+ from flashruntime.planner import plan, render
29
+ from flashruntime.protocol.plan_v1alpha1 import PlanRequest
30
+
31
+ if args.request_file.endswith((".yaml", ".yml")):
32
+ try:
33
+ import yaml
34
+ except ImportError:
35
+ print("pyyaml is required for YAML input: pip install pyyaml", file=sys.stderr)
36
+ return 2
37
+ with open(args.request_file) as f:
38
+ raw = yaml.safe_load(f)
39
+ else:
40
+ with open(args.request_file) as f:
41
+ raw = json.load(f)
42
+
43
+ try:
44
+ request = PlanRequest.model_validate(raw)
45
+ except Exception as exc: # pydantic ValidationError — show it plainly
46
+ print(f"invalid PlanRequest: {exc}", file=sys.stderr)
47
+ return 1
48
+
49
+ report = plan(request)
50
+ if args.json:
51
+ print(report.model_dump_json(indent=2, exclude_none=True))
52
+ else:
53
+ print(render(report))
54
+ return 0 if report.selected is not None else 3
55
+
56
+
57
+ def _submit(args) -> int:
58
+ """Build a CommandWorkload and run it locally via the SDK, then print a
59
+ human summary. Exit 0 iff SUCCEEDED. The SDK is imported here (not at
60
+ module top) so cli.py stays cheap on coordinator-only installs where the
61
+ other subcommands only need httpx."""
62
+ from flashruntime.sdk import submit
63
+ from flashruntime.workloads.command import CommandWorkload, Source
64
+
65
+ try:
66
+ task_params = json.loads(args.task_params) if args.task_params else None
67
+ except json.JSONDecodeError as exc: # a bad --task-params must not traceback
68
+ print(f"--task-params is not valid JSON: {exc}", file=sys.stderr)
69
+ return 2
70
+
71
+ workload = CommandWorkload(
72
+ command=args.cmd,
73
+ source=Source(path=args.source),
74
+ task_params=task_params,
75
+ )
76
+
77
+ # --watch is a tri-state: True (--watch) / False (--no-watch) / None
78
+ # (unset → submit() decides by TTY, off in CI). When on, submit() opens
79
+ # the live viewer and prints its URL, so the CLI just passes it through.
80
+ run = submit(
81
+ workload,
82
+ output_dir=args.output_dir,
83
+ max_restarts=args.max_restarts,
84
+ watch=args.watch,
85
+ )
86
+
87
+ print(f"state: {run.state.value}")
88
+ print(f"trials: {len(run.trials)}")
89
+ if workload.outputs.primary_metric:
90
+ best = run.best_trial()
91
+ print(f"best: {best}" if best else "best: (no trial reported the metric)")
92
+ print(f"output: {run.output_dir}")
93
+ return 0 if run.state.value == "SUCCEEDED" else 1
94
+
95
+
96
+ def main(argv: list[str] | None = None) -> int:
97
+ parser = argparse.ArgumentParser(prog="flashruntime")
98
+ parser.add_argument("--api", help="FlashRuntime API base URL")
99
+ sub = parser.add_subparsers(dest="command", required=True)
100
+
101
+ p_plan = sub.add_parser("plan", help="evaluate a PlanRequest offline and print the strategy")
102
+ p_plan.add_argument("request_file", help="PlanRequest as .yaml or .json")
103
+ p_plan.add_argument("--json", action="store_true", help="emit the full PlanReport as JSON")
104
+
105
+ p_submit = sub.add_parser("submit", help="run a command workload locally (no API needed)")
106
+ # dest is `cmd`, not `command`: the subparsers dest is already `command`
107
+ # (the subcommand name), and a positional named `command` would clobber it.
108
+ p_submit.add_argument("cmd", metavar="CMD", help="the command to run, e.g. 'python train.py --lr {lr}'")
109
+ p_submit.add_argument("--source", default=".", help="directory holding the user's code")
110
+ p_submit.add_argument("--task-params", help="JSON list of param dicts for Mode A fan-out")
111
+ p_submit.add_argument("--max-restarts", type=int, default=0, help="automatic recovery budget")
112
+ p_submit.add_argument("--output-dir", help="where run.json and artifacts land (default: temp dir)")
113
+ p_submit.add_argument(
114
+ "--watch",
115
+ action=argparse.BooleanOptionalAction,
116
+ default=None, # None ⇒ decide by TTY in _submit (never block/open a viewer in CI)
117
+ help="open the live viewer (default: on at a terminal, off in pipes/CI)",
118
+ )
119
+
120
+ p_submit_spec = sub.add_parser(
121
+ "submit-spec",
122
+ help=(
123
+ "POST a JobSpec YAML to the coordinator — was `submit` before 0.1.0; "
124
+ "renamed when `submit` became the local-workload front door"
125
+ ),
126
+ )
127
+ p_submit_spec.add_argument("spec_file")
128
+ for name in ("status", "events", "logs", "cancel"):
129
+ p = sub.add_parser(name)
130
+ p.add_argument("job_id")
131
+
132
+ args = parser.parse_args(argv)
133
+
134
+ if args.command == "plan":
135
+ return _plan(args)
136
+ if args.command == "submit":
137
+ return _submit(args)
138
+
139
+ import httpx
140
+
141
+ base = _api(args)
142
+ try:
143
+ if args.command == "submit-spec":
144
+ import yaml
145
+
146
+ with open(args.spec_file) as f:
147
+ spec = yaml.safe_load(f)
148
+ r = httpx.post(f"{base}/v1alpha1/jobs", json=spec, timeout=60)
149
+ elif args.command == "cancel":
150
+ r = httpx.post(f"{base}/v1alpha1/jobs/{args.job_id}/cancel", timeout=60)
151
+ elif args.command == "status":
152
+ r = httpx.get(f"{base}/v1alpha1/jobs/{args.job_id}", timeout=30)
153
+ else:
154
+ r = httpx.get(f"{base}/v1alpha1/jobs/{args.job_id}/{args.command}", timeout=30)
155
+ except httpx.ConnectError as exc:
156
+ print(f"cannot reach FlashRuntime API at {base}: {exc}", file=sys.stderr)
157
+ return 2
158
+
159
+ if r.status_code >= 400:
160
+ print(f"error {r.status_code}: {r.text}", file=sys.stderr)
161
+ return 1
162
+ print(json.dumps(r.json(), indent=2, default=str))
163
+ return 0
164
+
165
+
166
+ if __name__ == "__main__":
167
+ raise SystemExit(main())
@@ -0,0 +1,193 @@
1
+ """The coordinator's built-in status page.
2
+
3
+ One self-contained HTML page at GET / — no build step, no external assets,
4
+ no framework: it polls the same JSON API everything else uses
5
+ (/v1alpha1/nodes, /v1alpha1/jobs, /v1alpha1/jobs/{id}/{tasks,events,artifacts})
6
+ every 2 seconds. This is the self-hosted profile's window into the system;
7
+ FlashML Cloud's Next.js dashboard is the managed superset of the same data.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from fastapi import APIRouter
13
+ from fastapi.responses import HTMLResponse
14
+
15
+ from flashruntime.viewer.page import TOKENS
16
+
17
+ _RAW_PAGE = """<!doctype html>
18
+ <html lang="en">
19
+ <head>
20
+ <meta charset="utf-8">
21
+ <title>FlashRuntime — local coordinator</title>
22
+ <style>
23
+ :root { color-scheme: dark; }
24
+ * { box-sizing: border-box; margin: 0; }
25
+ body { font: 13px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace;
26
+ background: #0d1117; color: #c9d1d9; padding: 24px; }
27
+ h1 { font-size: 15px; margin-bottom: 4px; color: #e6edf3; }
28
+ h2 { font-size: 12px; text-transform: uppercase; letter-spacing: .08em;
29
+ color: #8b949e; margin: 24px 0 8px; }
30
+ .sub { color: #8b949e; margin-bottom: 12px; }
31
+ table { border-collapse: collapse; width: 100%; }
32
+ th, td { text-align: left; padding: 5px 12px 5px 0; border-bottom: 1px solid #21262d;
33
+ vertical-align: top; white-space: nowrap; }
34
+ th { color: #8b949e; font-weight: normal; }
35
+ td.wrap { white-space: normal; }
36
+ .dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%;
37
+ margin-right: 6px; }
38
+ .on { background: #3fb950; } .off { background: #f85149; }
39
+ .st-RUNNING { color: #58a6ff; } .st-SUCCEEDED, .st-COMPLETED { color: #3fb950; }
40
+ .st-FAILED { color: #f85149; } .st-PENDING { color: #8b949e; }
41
+ .st-LEASED { color: #d29922; } .st-RECOVERING { color: #d29922; }
42
+ .st-CANCELLED { color: #8b949e; }
43
+ tr.job { cursor: pointer; } tr.job:hover td { background: #161b22; }
44
+ tr.sel td { background: #161b22; }
45
+ #events { max-height: 320px; overflow-y: auto; }
46
+ .ev-time { color: #8b949e; margin-right: 8px; }
47
+ .ev-type { color: #d2a8ff; margin-right: 8px; }
48
+ a { color: #58a6ff; text-decoration: none; }
49
+ .cols { display: flex; gap: 40px; flex-wrap: wrap; }
50
+ .cols > div { flex: 1 1 420px; min-width: 0; overflow-x: auto; }
51
+ .muted { color: #8b949e; }
52
+ </style>
53
+ </head>
54
+ <body>
55
+ <h1>FlashRuntime — local coordinator</h1>
56
+ <div class="sub" id="health">connecting…</div>
57
+
58
+ <h2>Nodes</h2>
59
+ <table><thead><tr><th></th><th>node</th><th>host</th><th>env</th><th>cpu</th>
60
+ <th>ram</th><th>accepted tasks</th><th>last heartbeat</th></tr></thead>
61
+ <tbody id="nodes"><tr><td colspan="8" class="muted">no nodes registered</td></tr></tbody></table>
62
+
63
+ <h2>Jobs</h2>
64
+ <table><thead><tr><th>job</th><th>name</th><th>backend</th><th>state</th>
65
+ <th>tasks</th><th>created</th></tr></thead>
66
+ <tbody id="jobs"><tr><td colspan="6" class="muted">no jobs yet</td></tr></tbody></table>
67
+
68
+ <div class="cols">
69
+ <div>
70
+ <h2>Tasks <span class="muted" id="selJob"></span></h2>
71
+ <table><thead><tr><th>task</th><th>state</th><th>attempts</th><th>node</th></tr></thead>
72
+ <tbody id="tasks"><tr><td colspan="4" class="muted">select a job</td></tr></tbody></table>
73
+ <h2>Artifacts</h2>
74
+ <div id="artifacts" class="muted">select a job</div>
75
+ </div>
76
+ <div>
77
+ <h2>Events</h2>
78
+ <div id="events" class="muted">select a job</div>
79
+ </div>
80
+ </div>
81
+
82
+ <script>
83
+ const $ = id => document.getElementById(id);
84
+ let selected = null, taskCache = {};
85
+
86
+ const esc = s => String(s ?? "").replace(/[&<>"]/g, c =>
87
+ ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;"}[c]));
88
+ const st = s => `<span class="st-${esc(s)}">${esc(s)}</span>`;
89
+ const t = iso => iso ? new Date(iso).toLocaleTimeString() : "";
90
+
91
+ async function j(path) { const r = await fetch(path); if (!r.ok) throw r; return r.json(); }
92
+
93
+ async function tick() {
94
+ try {
95
+ const h = await j("/healthz");
96
+ $("health").textContent = `profile: ${h.profile} · ${new Date().toLocaleTimeString()}`;
97
+ } catch { $("health").textContent = "coordinator unreachable"; return; }
98
+
99
+ const nodes = await j("/v1alpha1/nodes");
100
+ $("nodes").innerHTML = nodes.length ? nodes.map(n => `<tr>
101
+ <td><span class="dot ${n.online ? "on" : "off"}"></span></td>
102
+ <td>${esc(n.node_id)}</td><td>${esc(n.hostname)}</td><td>${esc(n.environment)}</td>
103
+ <td>${n.capabilities.cpu_cores ?? "?"}</td>
104
+ <td>${n.capabilities.memory_bytes ? (n.capabilities.memory_bytes/1e9).toFixed(1)+" GB" : "?"}</td>
105
+ <td>${n.accepted_tasks}</td><td>${n.last_heartbeat_age_s}s ago</td></tr>`).join("")
106
+ : `<tr><td colspan="8" class="muted">no nodes registered</td></tr>`;
107
+
108
+ const jobs = await j("/v1alpha1/jobs");
109
+ for (const job of jobs) {
110
+ if (job.backend === "leases") {
111
+ try { taskCache[job.job_id] = await j(`/v1alpha1/jobs/${job.job_id}/tasks`); } catch {}
112
+ }
113
+ }
114
+ $("jobs").innerHTML = jobs.length ? jobs.map(job => {
115
+ const tasks = taskCache[job.job_id] || [];
116
+ const done = tasks.filter(x => x.state === "COMPLETED").length;
117
+ const summary = tasks.length ? `${done}/${tasks.length} completed` : "—";
118
+ return `<tr class="job ${job.job_id === selected ? "sel" : ""}" onclick="sel('${job.job_id}')">
119
+ <td>${esc(job.job_id)}</td><td>${esc(job.spec.metadata.name)}</td>
120
+ <td>${esc(job.backend)}</td><td>${st(job.state)}</td>
121
+ <td>${summary}</td><td>${t(job.created_at)}</td></tr>`;
122
+ }).join("") : `<tr><td colspan="6" class="muted">no jobs yet</td></tr>`;
123
+
124
+ if (selected) await detail(selected);
125
+ }
126
+
127
+ async function detail(id) {
128
+ $("selJob").textContent = "· " + id;
129
+ const tasks = taskCache[id] || [];
130
+ $("tasks").innerHTML = tasks.length ? tasks.map(x => `<tr>
131
+ <td>${esc(x.task_id)}</td><td>${st(x.state)}</td>
132
+ <td>${x.attempts}/${x.max_attempts}</td><td>${esc(x.node_id ?? "—")}</td></tr>`).join("")
133
+ : `<tr><td colspan="4" class="muted">no leased tasks (ray-backend job?)</td></tr>`;
134
+
135
+ try {
136
+ const events = await j(`/v1alpha1/jobs/${id}/events`);
137
+ $("events").innerHTML = events.slice().reverse().map(e =>
138
+ `<div><span class="ev-time">${t(e.timestamp)}</span>` +
139
+ `<span class="ev-type">${esc(e.type)}</span>${esc(e.message)}</div>`).join("");
140
+ } catch {}
141
+ try {
142
+ const arts = await j(`/v1alpha1/jobs/${id}/artifacts`);
143
+ $("artifacts").innerHTML = arts.length ? arts.map(a =>
144
+ `<div><a href="/v1alpha1/artifacts/${esc(a.key)}" target="_blank">${esc(a.key)}</a>` +
145
+ ` <span class="muted">(${a.size_bytes} B)</span></div>`).join("") : "none yet";
146
+ } catch {}
147
+ }
148
+
149
+ function sel(id) { selected = id; tick(); }
150
+ tick(); setInterval(tick, 2000);
151
+ </script>
152
+ </body>
153
+ </html>"""
154
+
155
+
156
+ # Palette alignment: the dashboard was built with ad-hoc GitHub-dark hexes;
157
+ # map each to the shared token in `viewer.page.TOKENS` so this page and the
158
+ # run viewer read as one house style. VALUES only — the page structure is
159
+ # untouched. The neutrals map to themselves; the accents pick up the oklch
160
+ # tokens (cyan running, green ok, amber warn, red fail, violet checkpoints).
161
+ _PALETTE = {
162
+ "#0d1117": TOKENS["bg"],
163
+ "#161b22": TOKENS["panel"],
164
+ "#21262d": TOKENS["border"],
165
+ "#c9d1d9": TOKENS["text"],
166
+ "#e6edf3": TOKENS["text_bright"],
167
+ "#8b949e": TOKENS["muted"],
168
+ "#3fb950": TOKENS["ok"], # on / SUCCEEDED / COMPLETED
169
+ "#f85149": TOKENS["fail"], # off / FAILED
170
+ "#58a6ff": TOKENS["running"], # RUNNING / links
171
+ "#d29922": TOKENS["warn"], # LEASED / RECOVERING
172
+ "#d2a8ff": TOKENS["ckpt"], # event type accent
173
+ }
174
+
175
+
176
+ def _recolor(page: str) -> str:
177
+ """Swap every ad-hoc hex for its shared token (single source of truth)."""
178
+ for old, new in _PALETTE.items():
179
+ page = page.replace(old, new)
180
+ return page
181
+
182
+
183
+ _PAGE = _recolor(_RAW_PAGE)
184
+
185
+
186
+ def build_router() -> APIRouter:
187
+ router = APIRouter()
188
+
189
+ @router.get("/", include_in_schema=False)
190
+ async def index() -> HTMLResponse:
191
+ return HTMLResponse(_PAGE)
192
+
193
+ return router
@@ -0,0 +1,101 @@
1
+ """Append-only job/event ledger on SQLite.
2
+
3
+ POC-scale persistence: one file, synchronous sqlite3 behind a lock, called
4
+ via asyncio.to_thread. Postgres is a flashml-cloud concern; the runtime's
5
+ ledger only needs durability and ordering.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import sqlite3
12
+ import threading
13
+ from datetime import datetime, timezone
14
+ from pathlib import Path
15
+
16
+ from flashruntime.protocol.v1alpha1 import (
17
+ ArtifactRecord,
18
+ Event,
19
+ JobRecord,
20
+ JobState,
21
+ )
22
+
23
+ _SCHEMA = """
24
+ CREATE TABLE IF NOT EXISTS jobs (
25
+ job_id TEXT PRIMARY KEY,
26
+ record TEXT NOT NULL,
27
+ state TEXT NOT NULL,
28
+ created_at TEXT NOT NULL
29
+ );
30
+ CREATE TABLE IF NOT EXISTS events (
31
+ seq INTEGER PRIMARY KEY AUTOINCREMENT,
32
+ job_id TEXT NOT NULL,
33
+ type TEXT NOT NULL,
34
+ timestamp TEXT NOT NULL,
35
+ payload TEXT NOT NULL
36
+ );
37
+ CREATE INDEX IF NOT EXISTS idx_events_job ON events (job_id, seq);
38
+ """
39
+
40
+
41
+ class Ledger:
42
+ def __init__(self, path: str | Path):
43
+ self._path = str(path)
44
+ self._lock = threading.Lock()
45
+ with self._conn() as conn:
46
+ conn.executescript(_SCHEMA)
47
+
48
+ def _conn(self) -> sqlite3.Connection:
49
+ conn = sqlite3.connect(self._path)
50
+ conn.row_factory = sqlite3.Row
51
+ return conn
52
+
53
+ # -- jobs --------------------------------------------------------------
54
+
55
+ def upsert_job(self, job: JobRecord) -> None:
56
+ with self._lock, self._conn() as conn:
57
+ conn.execute(
58
+ "INSERT INTO jobs (job_id, record, state, created_at) VALUES (?,?,?,?) "
59
+ "ON CONFLICT(job_id) DO UPDATE SET record=excluded.record, state=excluded.state",
60
+ (
61
+ job.job_id,
62
+ job.model_dump_json(),
63
+ job.state.value,
64
+ job.created_at.isoformat(),
65
+ ),
66
+ )
67
+
68
+ def get_job(self, job_id: str) -> JobRecord | None:
69
+ with self._lock, self._conn() as conn:
70
+ row = conn.execute(
71
+ "SELECT record FROM jobs WHERE job_id=?", (job_id,)
72
+ ).fetchone()
73
+ return JobRecord.model_validate_json(row["record"]) if row else None
74
+
75
+ def list_jobs(self) -> list[JobRecord]:
76
+ with self._lock, self._conn() as conn:
77
+ rows = conn.execute(
78
+ "SELECT record FROM jobs ORDER BY created_at DESC"
79
+ ).fetchall()
80
+ return [JobRecord.model_validate_json(r["record"]) for r in rows]
81
+
82
+ # -- events ------------------------------------------------------------
83
+
84
+ def append_event(self, event: Event) -> None:
85
+ with self._lock, self._conn() as conn:
86
+ conn.execute(
87
+ "INSERT INTO events (job_id, type, timestamp, payload) VALUES (?,?,?,?)",
88
+ (
89
+ event.job_id,
90
+ event.type.value,
91
+ event.timestamp.isoformat(),
92
+ event.model_dump_json(),
93
+ ),
94
+ )
95
+
96
+ def events_for(self, job_id: str) -> list[Event]:
97
+ with self._lock, self._conn() as conn:
98
+ rows = conn.execute(
99
+ "SELECT payload FROM events WHERE job_id=? ORDER BY seq", (job_id,)
100
+ ).fetchall()
101
+ return [Event.model_validate_json(r["payload"]) for r in rows]