lean-runtime 0.6.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.
@@ -0,0 +1,1170 @@
1
+ """Published environments and reproducible executions within them."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import os
8
+ import re
9
+ import shutil
10
+ import subprocess
11
+ import tempfile
12
+ import threading
13
+ import uuid
14
+ from collections.abc import Callable, Mapping, Sequence
15
+ from concurrent.futures import Future, ThreadPoolExecutor
16
+ from contextlib import suppress
17
+ from dataclasses import asdict, dataclass
18
+ from datetime import datetime, timezone
19
+ from pathlib import Path, PurePosixPath
20
+ from typing import Any, Generic, TextIO, TypeVar, cast
21
+
22
+ from .backends import Backend, BackendResult, InteractiveProcess, InteractiveTextReader
23
+ from .diagnostics import error_diagnostic, parse_diagnostics
24
+ from .errors import EnvironmentError, MaterializationError, PolicyError
25
+ from .events import EventEmitter
26
+ from .lake import ROOT_MODULE
27
+ from .lockfiles import EnvironmentLock
28
+ from .locking import FileLock
29
+ from .models import (
30
+ ExecutionProvenance,
31
+ ExecutionResult,
32
+ PackageProvenance,
33
+ )
34
+ from .policies import ExecutionPolicy
35
+ from .serialization import sha256_id, write_json_atomic
36
+ from .store import EnvironmentStore, clone_tree, environment_identity, platform_record
37
+ from .toolchains import ToolchainManager
38
+
39
+ ENVIRONMENT_SCHEMA = "lean-runtime-published-environment/1"
40
+ EXECUTION_SCHEMA = "lean-runtime-execution/1"
41
+ CAPTURE_SCHEMA = "lean-runtime-execution-capture/1"
42
+ T = TypeVar("T")
43
+ _IMPORT = re.compile(r"^\s*import\s+(.+?)\s*$", re.MULTILINE)
44
+
45
+
46
+ def _now() -> str:
47
+ return datetime.now(timezone.utc).isoformat()
48
+
49
+
50
+ def _lean_path(value: str) -> str:
51
+ normalized = PurePosixPath(value.replace("\\", "/"))
52
+ if (
53
+ not value
54
+ or normalized.is_absolute()
55
+ or ".." in normalized.parts
56
+ or normalized.suffix != ".lean"
57
+ ):
58
+ raise EnvironmentError(f"unsafe Lean source path: {value!r}")
59
+ return normalized.as_posix()
60
+
61
+
62
+ def _source_files(files: Mapping[str, str]) -> dict[str, str]:
63
+ if not files:
64
+ raise EnvironmentError("a check requires at least one Lean source file")
65
+ result: dict[str, str] = {}
66
+ for name, source in files.items():
67
+ if not isinstance(name, str) or not isinstance(source, str):
68
+ raise EnvironmentError("Lean source files must map paths to strings")
69
+ normalized = _lean_path(name)
70
+ if normalized in result:
71
+ raise EnvironmentError(f"duplicate normalized Lean source path: {normalized}")
72
+ result[normalized] = source
73
+ return result
74
+
75
+
76
+ def _execution_command(command: Sequence[str]) -> tuple[str, ...]:
77
+ if not command:
78
+ raise EnvironmentError("an execution command must not be empty")
79
+ normalized = tuple(command)
80
+ if any(not isinstance(part, str) or not part or "\0" in part for part in normalized):
81
+ raise EnvironmentError("execution command elements must be non-empty strings without NULs")
82
+ return normalized
83
+
84
+
85
+ def _module_name(path: str) -> str:
86
+ return path.removesuffix(".lean").replace("/", ".")
87
+
88
+
89
+ def _support_order(files: Mapping[str, str], entrypoint: str) -> tuple[str, ...]:
90
+ """Topologically order submitted modules needed before the entrypoint."""
91
+ paths_by_module = {_module_name(path): path for path in files}
92
+ dependencies: dict[str, set[str]] = {}
93
+ for path, source in files.items():
94
+ imported: set[str] = set()
95
+ for match in _IMPORT.finditer(source):
96
+ for module in match.group(1).split():
97
+ if module in paths_by_module:
98
+ imported.add(paths_by_module[module])
99
+ dependencies[path] = imported
100
+
101
+ ordered: list[str] = []
102
+ visiting: set[str] = set()
103
+ visited: set[str] = set()
104
+
105
+ def visit(path: str) -> None:
106
+ if path in visited:
107
+ return
108
+ if path in visiting:
109
+ raise EnvironmentError(f"submitted Lean sources contain an import cycle at {path}")
110
+ visiting.add(path)
111
+ for dependency in sorted(dependencies[path]):
112
+ visit(dependency)
113
+ visiting.remove(path)
114
+ visited.add(path)
115
+ ordered.append(path)
116
+
117
+ visit(entrypoint)
118
+ return tuple(path for path in ordered if path != entrypoint)
119
+
120
+
121
+ def _package_import_targets(files: Mapping[str, str], lock: EnvironmentLock) -> tuple[str, ...]:
122
+ """Find imported package roots whose artifacts Lake may need on demand."""
123
+ package_names = {package.name.lower() for package in lock.packages}
124
+ package_names.update(
125
+ package.root_module.split(".", 1)[0].lower()
126
+ for package in lock.packages
127
+ if package.root_module
128
+ )
129
+ local_modules = {_module_name(path) for path in files}
130
+ targets: set[str] = set()
131
+ for source in files.values():
132
+ for match in _IMPORT.finditer(source):
133
+ for module in match.group(1).split():
134
+ root = module.split(".", 1)[0]
135
+ if module not in local_modules and root.lower() in package_names:
136
+ targets.add(root)
137
+ return tuple(sorted(targets))
138
+
139
+
140
+ @dataclass(frozen=True, slots=True)
141
+ class EnvironmentInfo:
142
+ environment_id: str
143
+ lock_id: str
144
+ toolchain: str
145
+ packages: int
146
+ platform: dict[str, str]
147
+ build_profile: str
148
+ status: str
149
+ created_at: str
150
+ names: tuple[str, ...] = ()
151
+
152
+ def to_dict(self) -> dict[str, Any]:
153
+ return asdict(self)
154
+
155
+
156
+ @dataclass(frozen=True, slots=True)
157
+ class ExecutionCapture:
158
+ environment_id: str
159
+ lock: EnvironmentLock
160
+ operation: str
161
+ files: dict[str, str]
162
+ entrypoint: str
163
+ policy: ExecutionPolicy
164
+ expected_ok: bool | None = None
165
+
166
+ @property
167
+ def capture_id(self) -> str:
168
+ return sha256_id("capture", self.to_dict(include_id=False))
169
+
170
+ @property
171
+ def lock_id(self) -> str:
172
+ return self.lock.lock_id
173
+
174
+ def to_dict(self, *, include_id: bool = True) -> dict[str, Any]:
175
+ value = {
176
+ "schema": CAPTURE_SCHEMA,
177
+ "environment_id": self.environment_id,
178
+ "lock": self.lock.to_dict(),
179
+ "operation": self.operation,
180
+ "files": dict(sorted(self.files.items())),
181
+ "entrypoint": self.entrypoint,
182
+ "policy": self.policy.to_dict(),
183
+ "expected_ok": self.expected_ok,
184
+ }
185
+ return {"capture_id": self.capture_id, **value} if include_id else value
186
+
187
+ def write(self, path: str | os.PathLike[str]) -> None:
188
+ write_json_atomic(Path(path), self.to_dict())
189
+
190
+ @classmethod
191
+ def from_dict(cls, value: dict[str, Any]) -> ExecutionCapture:
192
+ if value.get("schema") != CAPTURE_SCHEMA:
193
+ raise EnvironmentError(f"unsupported capture schema: {value.get('schema')!r}")
194
+ files = value.get("files")
195
+ if not isinstance(files, dict) or not files:
196
+ raise EnvironmentError("execution capture files must be a non-empty object")
197
+ normalized_files: dict[str, str] = {}
198
+ for name, source in files.items():
199
+ if not isinstance(name, str) or not isinstance(source, str):
200
+ raise EnvironmentError("execution capture contains an unsafe file entry")
201
+ normalized = _lean_path(name)
202
+ if normalized in normalized_files:
203
+ raise EnvironmentError(
204
+ f"execution capture contains duplicate source path: {normalized}"
205
+ )
206
+ normalized_files[normalized] = source
207
+ entrypoint_value = value.get("entrypoint")
208
+ if entrypoint_value is None and len(normalized_files) == 1:
209
+ entrypoint_value = next(iter(normalized_files))
210
+ if not isinstance(entrypoint_value, str):
211
+ raise EnvironmentError("execution capture entrypoint must be a Lean source path")
212
+ entrypoint = _lean_path(entrypoint_value)
213
+ if entrypoint not in normalized_files:
214
+ raise EnvironmentError("execution capture entrypoint is not present in files")
215
+ policy_value = value.get("policy")
216
+ if not isinstance(policy_value, dict):
217
+ raise EnvironmentError("execution capture policy must be an object")
218
+ expected_ok = value.get("expected_ok")
219
+ if expected_ok is not None and not isinstance(expected_ok, bool):
220
+ raise EnvironmentError("execution capture expected_ok must be Boolean or null")
221
+ capture = cls(
222
+ environment_id=str(value["environment_id"]),
223
+ lock=EnvironmentLock.from_dict(dict(value["lock"])),
224
+ operation=str(value["operation"]),
225
+ files=normalized_files,
226
+ entrypoint=entrypoint,
227
+ policy=ExecutionPolicy(**policy_value),
228
+ expected_ok=expected_ok,
229
+ )
230
+ recorded = value.get("capture_id")
231
+ if recorded is not None and recorded != capture.capture_id:
232
+ raise EnvironmentError("execution capture identity mismatch")
233
+ return capture
234
+
235
+ @classmethod
236
+ def load(cls, path: str | os.PathLike[str]) -> ExecutionCapture:
237
+ value = json.loads(Path(path).read_text(encoding="utf-8"))
238
+ if not isinstance(value, dict):
239
+ raise EnvironmentError("execution capture must contain an object")
240
+ return cls.from_dict(value)
241
+
242
+
243
+ class ExecutionJob(Generic[T]):
244
+ """A cancellable background execution."""
245
+
246
+ def __init__(self, function: Any) -> None:
247
+ self._cancel = threading.Event()
248
+ self._executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="lean-runtime")
249
+ self._future: Future[T] = self._executor.submit(function, self._cancel)
250
+
251
+ def cancel(self) -> bool:
252
+ if self._future.done():
253
+ return False
254
+ self._cancel.set()
255
+ return True
256
+
257
+ def done(self) -> bool:
258
+ return self._future.done()
259
+
260
+ def result(self, timeout: float | None = None) -> T:
261
+ try:
262
+ return self._future.result(timeout=timeout)
263
+ finally:
264
+ if self._future.done():
265
+ self._executor.shutdown(wait=False)
266
+
267
+
268
+ class InteractiveSession:
269
+ """One live process inside a disposable environment instance."""
270
+
271
+ def __init__(
272
+ self,
273
+ *,
274
+ process: InteractiveProcess,
275
+ execution_id: str,
276
+ finalize: Callable[[BackendResult], ExecutionResult],
277
+ cleanup: Callable[[], None],
278
+ ) -> None:
279
+ self._process = process
280
+ self._execution_id = execution_id
281
+ self._finalize = finalize
282
+ self._cleanup = cleanup
283
+ self._result: ExecutionResult | None = None
284
+ self._closing = threading.Lock()
285
+
286
+ @property
287
+ def stdin(self) -> TextIO:
288
+ return self._process.stdin
289
+
290
+ @property
291
+ def stdout(self) -> InteractiveTextReader:
292
+ return self._process.stdout
293
+
294
+ @property
295
+ def stderr(self) -> InteractiveTextReader:
296
+ return self._process.stderr
297
+
298
+ @property
299
+ def execution_id(self) -> str:
300
+ return self._execution_id
301
+
302
+ @property
303
+ def running(self) -> bool:
304
+ return self._result is None and self._process.poll() is None
305
+
306
+ def poll(self) -> int | None:
307
+ """Return the process exit code, or ``None`` while it is running."""
308
+ return self._process.poll()
309
+
310
+ def close(self) -> ExecutionResult:
311
+ """Send EOF, stop if necessary, persist provenance, and remove the instance."""
312
+ with self._closing:
313
+ if self._result is not None:
314
+ return self._result
315
+ try:
316
+ self._result = self._finalize(self._process.finish())
317
+ return self._result
318
+ finally:
319
+ self._cleanup()
320
+
321
+ def __enter__(self) -> InteractiveSession:
322
+ return self
323
+
324
+ def __exit__(self, _exc_type: object, _exc_value: object, _traceback: object) -> None:
325
+ self.close()
326
+
327
+
328
+ class Environment:
329
+ """A lightweight handle to one published, content-addressed environment."""
330
+
331
+ def __init__(
332
+ self,
333
+ manager: EnvironmentManager,
334
+ environment_id: str,
335
+ lock: EnvironmentLock,
336
+ root: Path,
337
+ record: dict[str, Any],
338
+ ) -> None:
339
+ self.manager = manager
340
+ self.id = environment_id
341
+ self.lock = lock
342
+ self.root = root
343
+ self.workspace = root / "workspace"
344
+ self._record = record
345
+
346
+ def inspect(self) -> EnvironmentInfo:
347
+ names = tuple(
348
+ sorted(name for name, value in self.manager.store.aliases().items() if value == self.id)
349
+ )
350
+ return EnvironmentInfo(
351
+ environment_id=self.id,
352
+ lock_id=self.lock.lock_id,
353
+ toolchain=self.lock.toolchain,
354
+ packages=len(self.lock.packages),
355
+ platform=dict(self._record["platform"]),
356
+ build_profile=str(self._record["build_profile"]),
357
+ status=str(self._record["status"]),
358
+ created_at=str(self._record["created_at"]),
359
+ names=names,
360
+ )
361
+
362
+ def check(
363
+ self,
364
+ source: str,
365
+ *,
366
+ filename: str = "Main.lean",
367
+ policy: ExecutionPolicy | None = None,
368
+ cancel: threading.Event | None = None,
369
+ ) -> ExecutionResult:
370
+ safe_filename = Path(filename).name
371
+ if not safe_filename.endswith(".lean"):
372
+ safe_filename += ".lean"
373
+ return self.check_files(
374
+ {safe_filename: source},
375
+ entrypoint=safe_filename,
376
+ policy=policy,
377
+ cancel=cancel,
378
+ )
379
+
380
+ def check_files(
381
+ self,
382
+ files: Mapping[str, str],
383
+ *,
384
+ entrypoint: str = "Main.lean",
385
+ policy: ExecutionPolicy | None = None,
386
+ cancel: threading.Event | None = None,
387
+ ) -> ExecutionResult:
388
+ """Check a safe relative tree of Lean files through one entrypoint."""
389
+ normalized = _source_files(files)
390
+ selected_entrypoint = _lean_path(entrypoint)
391
+ if selected_entrypoint not in normalized:
392
+ raise EnvironmentError(f"entrypoint is not present in files: {selected_entrypoint}")
393
+ return self._execute_in_instance(
394
+ operation="check",
395
+ files=normalized,
396
+ entrypoint=selected_entrypoint,
397
+ targets=(),
398
+ requested_command=(),
399
+ policy=policy or ExecutionPolicy(),
400
+ cancel=cancel,
401
+ )
402
+
403
+ def start_check(
404
+ self,
405
+ source: str,
406
+ *,
407
+ filename: str = "Main.lean",
408
+ policy: ExecutionPolicy | None = None,
409
+ ) -> ExecutionJob[ExecutionResult]:
410
+ return ExecutionJob(
411
+ lambda cancel: self.check(source, filename=filename, policy=policy, cancel=cancel)
412
+ )
413
+
414
+ async def check_async(
415
+ self,
416
+ source: str,
417
+ *,
418
+ filename: str = "Main.lean",
419
+ policy: ExecutionPolicy | None = None,
420
+ ) -> ExecutionResult:
421
+ """Check source without blocking an asyncio event loop."""
422
+ job = self.start_check(source, filename=filename, policy=policy)
423
+ return await self._await_job(job)
424
+
425
+ async def check_files_async(
426
+ self,
427
+ files: Mapping[str, str],
428
+ *,
429
+ entrypoint: str = "Main.lean",
430
+ policy: ExecutionPolicy | None = None,
431
+ ) -> ExecutionResult:
432
+ """Check a multi-file request and propagate coroutine cancellation."""
433
+ job: ExecutionJob[ExecutionResult] = ExecutionJob(
434
+ lambda cancel: self.check_files(
435
+ files, entrypoint=entrypoint, policy=policy, cancel=cancel
436
+ )
437
+ )
438
+ return await self._await_job(job)
439
+
440
+ @staticmethod
441
+ async def _await_job(job: ExecutionJob[ExecutionResult]) -> ExecutionResult:
442
+ try:
443
+ return await asyncio.to_thread(job.result)
444
+ except asyncio.CancelledError:
445
+ job.cancel()
446
+ while not job.done():
447
+ await asyncio.sleep(0.01)
448
+ job.result()
449
+ raise
450
+
451
+ def check_many(
452
+ self,
453
+ sources: Sequence[str],
454
+ *,
455
+ concurrency: int = 4,
456
+ policy: ExecutionPolicy | None = None,
457
+ ) -> tuple[ExecutionResult, ...]:
458
+ if concurrency < 1:
459
+ raise ValueError("concurrency must be positive")
460
+ with ThreadPoolExecutor(max_workers=concurrency) as executor:
461
+ futures = [executor.submit(self.check, source, policy=policy) for source in sources]
462
+ return tuple(future.result() for future in futures)
463
+
464
+ async def check_many_async(
465
+ self,
466
+ sources: Sequence[str],
467
+ *,
468
+ concurrency: int = 4,
469
+ policy: ExecutionPolicy | None = None,
470
+ ) -> tuple[ExecutionResult, ...]:
471
+ if concurrency < 1:
472
+ raise ValueError("concurrency must be positive")
473
+ semaphore = asyncio.Semaphore(concurrency)
474
+
475
+ async def check_one(source: str) -> ExecutionResult:
476
+ async with semaphore:
477
+ return await self.check_async(source, policy=policy)
478
+
479
+ return tuple(await asyncio.gather(*(check_one(source) for source in sources)))
480
+
481
+ def build(
482
+ self,
483
+ targets: Sequence[str] = (),
484
+ *,
485
+ policy: ExecutionPolicy | None = None,
486
+ cancel: threading.Event | None = None,
487
+ ) -> ExecutionResult:
488
+ return self._execute_in_instance(
489
+ operation="build",
490
+ files={},
491
+ entrypoint=None,
492
+ targets=tuple(targets),
493
+ requested_command=(),
494
+ policy=policy or ExecutionPolicy(timeout_seconds=900, max_output_bytes=10_000_000),
495
+ cancel=cancel,
496
+ )
497
+
498
+ def execute(
499
+ self,
500
+ command: Sequence[str],
501
+ *,
502
+ policy: ExecutionPolicy | None = None,
503
+ cancel: threading.Event | None = None,
504
+ ) -> ExecutionResult:
505
+ """Run a command, such as ``lake exe TARGET``, in a disposable instance."""
506
+ return self._execute_in_instance(
507
+ operation="execute",
508
+ files={},
509
+ entrypoint=None,
510
+ targets=(),
511
+ requested_command=_execution_command(command),
512
+ policy=policy or ExecutionPolicy(),
513
+ cancel=cancel,
514
+ )
515
+
516
+ def spawn_interactive(
517
+ self,
518
+ command: Sequence[str],
519
+ *,
520
+ policy: ExecutionPolicy | None = None,
521
+ ) -> InteractiveSession:
522
+ """Start a long-running command with live text pipes in a disposable instance."""
523
+ requested_command = _execution_command(command)
524
+ selected_policy = policy or ExecutionPolicy()
525
+ source_digest = sha256_id("files", {})
526
+ request_digest = sha256_id(
527
+ "request",
528
+ {
529
+ "schema": EXECUTION_SCHEMA,
530
+ "environment_id": self.id,
531
+ "operation": "interactive",
532
+ "source_digest": source_digest,
533
+ "command": list(requested_command),
534
+ "policy": selected_policy.to_dict(),
535
+ "backend": self.manager.backend.name,
536
+ },
537
+ )
538
+ started_at = _now()
539
+ execution_id = sha256_id(
540
+ "execution",
541
+ {
542
+ "request_digest": request_digest,
543
+ "started_at": started_at,
544
+ "nonce": uuid.uuid4().hex,
545
+ },
546
+ )
547
+ job_parent = self.manager.store.jobs / execution_id
548
+ job_parent.mkdir(parents=True, exist_ok=True)
549
+ instance = job_parent / f"instance-{uuid.uuid4().hex}"
550
+
551
+ def cleanup() -> None:
552
+ if instance.exists():
553
+ shutil.rmtree(instance)
554
+ with suppress(OSError):
555
+ job_parent.rmdir()
556
+
557
+ try:
558
+ with self.manager.store.execution_lease(self.id):
559
+ clone_tree(self.workspace, instance)
560
+ resolved_command = self.manager.toolchains.command(
561
+ self.lock.toolchain, requested_command[0], *requested_command[1:]
562
+ )
563
+ spawn = getattr(self.manager.backend, "spawn_interactive", None)
564
+ if not callable(spawn):
565
+ raise PolicyError(
566
+ f"backend {self.manager.backend.name!r} does not support interactive execution"
567
+ )
568
+ process = cast(
569
+ InteractiveProcess,
570
+ spawn(
571
+ resolved_command,
572
+ cwd=instance,
573
+ environment=self.manager.toolchains.environment,
574
+ policy=selected_policy,
575
+ ),
576
+ )
577
+ except BaseException:
578
+ cleanup()
579
+ raise
580
+
581
+ def finalize(raw: BackendResult) -> ExecutionResult:
582
+ result = self._result(
583
+ raw,
584
+ command=resolved_command,
585
+ cwd=instance,
586
+ execution_id=execution_id,
587
+ request_digest=request_digest,
588
+ source_digest=source_digest,
589
+ started_at=started_at,
590
+ policy=selected_policy,
591
+ )
592
+ self._record_execution(
593
+ result,
594
+ "interactive",
595
+ (),
596
+ None,
597
+ (),
598
+ requested_command,
599
+ )
600
+ return result
601
+
602
+ return InteractiveSession(
603
+ process=process,
604
+ execution_id=execution_id,
605
+ finalize=finalize,
606
+ cleanup=cleanup,
607
+ )
608
+
609
+ def capture(
610
+ self,
611
+ source: str,
612
+ *,
613
+ filename: str = "Main.lean",
614
+ policy: ExecutionPolicy | None = None,
615
+ expected_ok: bool | None = None,
616
+ ) -> ExecutionCapture:
617
+ safe_filename = Path(filename).name
618
+ if not safe_filename.endswith(".lean"):
619
+ safe_filename += ".lean"
620
+ return self.capture_files(
621
+ {safe_filename: source},
622
+ entrypoint=safe_filename,
623
+ policy=policy,
624
+ expected_ok=expected_ok,
625
+ )
626
+
627
+ def capture_files(
628
+ self,
629
+ files: Mapping[str, str],
630
+ *,
631
+ entrypoint: str = "Main.lean",
632
+ policy: ExecutionPolicy | None = None,
633
+ expected_ok: bool | None = None,
634
+ ) -> ExecutionCapture:
635
+ normalized = _source_files(files)
636
+ selected_entrypoint = _lean_path(entrypoint)
637
+ if selected_entrypoint not in normalized:
638
+ raise EnvironmentError(f"entrypoint is not present in files: {selected_entrypoint}")
639
+ return ExecutionCapture(
640
+ environment_id=self.id,
641
+ lock=self.lock,
642
+ operation="check",
643
+ files=normalized,
644
+ entrypoint=selected_entrypoint,
645
+ policy=policy or ExecutionPolicy(),
646
+ expected_ok=expected_ok,
647
+ )
648
+
649
+ def _execute_in_instance(
650
+ self,
651
+ *,
652
+ operation: str,
653
+ files: dict[str, str],
654
+ entrypoint: str | None,
655
+ targets: tuple[str, ...],
656
+ requested_command: tuple[str, ...],
657
+ policy: ExecutionPolicy,
658
+ cancel: threading.Event | None,
659
+ ) -> ExecutionResult:
660
+ source_digest = sha256_id("files", dict(sorted(files.items())))
661
+ request_digest = sha256_id(
662
+ "request",
663
+ {
664
+ "schema": EXECUTION_SCHEMA,
665
+ "environment_id": self.id,
666
+ "operation": operation,
667
+ "source_digest": source_digest,
668
+ "entrypoint": entrypoint,
669
+ "targets": list(targets),
670
+ "command": list(requested_command),
671
+ "policy": policy.to_dict(),
672
+ "backend": self.manager.backend.name,
673
+ },
674
+ )
675
+ started_at = _now()
676
+ execution_id = sha256_id(
677
+ "execution",
678
+ {
679
+ "request_digest": request_digest,
680
+ "started_at": started_at,
681
+ "nonce": uuid.uuid4().hex,
682
+ },
683
+ )
684
+ job_parent = self.manager.store.jobs / execution_id
685
+ job_parent.mkdir(parents=True, exist_ok=True)
686
+ instance = job_parent / f"instance-{uuid.uuid4().hex}"
687
+ try:
688
+ with self.manager.store.execution_lease(self.id):
689
+ clone_tree(self.workspace, instance)
690
+ preliminary: list[BackendResult] = []
691
+ if operation == "check":
692
+ assert entrypoint is not None
693
+ for name, source in files.items():
694
+ source_path = instance / name
695
+ source_path.parent.mkdir(parents=True, exist_ok=True)
696
+ source_path.write_text(source, encoding="utf-8")
697
+ import_targets = _package_import_targets(files, self.lock)
698
+ if import_targets:
699
+ import_command = self.manager.toolchains.command(
700
+ self.lock.toolchain, "lake", "build", *import_targets
701
+ )
702
+ import_result = self.manager.backend.execute(
703
+ import_command,
704
+ cwd=instance,
705
+ environment=self.manager.toolchains.environment,
706
+ policy=policy,
707
+ cancel=cancel,
708
+ )
709
+ preliminary.append(import_result)
710
+ if import_result.exit_code:
711
+ result = self._result(
712
+ import_result,
713
+ command=import_command,
714
+ cwd=instance,
715
+ execution_id=execution_id,
716
+ request_digest=request_digest,
717
+ source_digest=source_digest,
718
+ started_at=started_at,
719
+ policy=policy,
720
+ )
721
+ self._record_execution(
722
+ result,
723
+ operation,
724
+ targets,
725
+ entrypoint,
726
+ tuple(files),
727
+ requested_command,
728
+ )
729
+ return result
730
+ for support in _support_order(files, entrypoint):
731
+ support_path = instance / support
732
+ output_path = (
733
+ instance / ".lake" / "build" / "lib" / "lean" / support
734
+ ).with_suffix(".olean")
735
+ output_path.parent.mkdir(parents=True, exist_ok=True)
736
+ support_command = self.manager.toolchains.command(
737
+ self.lock.toolchain,
738
+ "lake",
739
+ "env",
740
+ "lean",
741
+ "-R",
742
+ str(instance),
743
+ "-o",
744
+ str(output_path),
745
+ str(support_path),
746
+ )
747
+ support_result = self.manager.backend.execute(
748
+ support_command,
749
+ cwd=instance,
750
+ environment=self.manager.toolchains.environment,
751
+ policy=policy,
752
+ cancel=cancel,
753
+ )
754
+ preliminary.append(support_result)
755
+ if support_result.exit_code:
756
+ result = self._result(
757
+ support_result,
758
+ command=support_command,
759
+ cwd=instance,
760
+ execution_id=execution_id,
761
+ request_digest=request_digest,
762
+ source_digest=source_digest,
763
+ started_at=started_at,
764
+ policy=policy,
765
+ )
766
+ self._record_execution(
767
+ result,
768
+ operation,
769
+ targets,
770
+ entrypoint,
771
+ tuple(files),
772
+ requested_command,
773
+ )
774
+ return result
775
+ source_path = instance / entrypoint
776
+ command = self.manager.toolchains.command(
777
+ self.lock.toolchain, "lake", "env", "lean", str(source_path)
778
+ )
779
+ elif operation == "build":
780
+ command = self.manager.toolchains.command(
781
+ self.lock.toolchain, "lake", "build", *targets
782
+ )
783
+ else:
784
+ command = self.manager.toolchains.command(
785
+ self.lock.toolchain, requested_command[0], *requested_command[1:]
786
+ )
787
+ raw = self.manager.backend.execute(
788
+ command,
789
+ cwd=instance,
790
+ environment=self.manager.toolchains.environment,
791
+ policy=policy,
792
+ cancel=cancel,
793
+ )
794
+ if operation == "check" and preliminary:
795
+ raw = BackendResult(
796
+ exit_code=raw.exit_code,
797
+ stdout="".join([item.stdout for item in preliminary] + [raw.stdout]),
798
+ stderr="".join([item.stderr for item in preliminary] + [raw.stderr]),
799
+ elapsed_seconds=sum(item.elapsed_seconds for item in preliminary)
800
+ + raw.elapsed_seconds,
801
+ timed_out=raw.timed_out or any(item.timed_out for item in preliminary),
802
+ cancelled=raw.cancelled or any(item.cancelled for item in preliminary),
803
+ output_truncated=raw.output_truncated
804
+ or any(item.output_truncated for item in preliminary),
805
+ enforced_policy_fields=raw.enforced_policy_fields,
806
+ )
807
+ result = self._result(
808
+ raw,
809
+ command=command,
810
+ cwd=instance,
811
+ execution_id=execution_id,
812
+ request_digest=request_digest,
813
+ source_digest=source_digest,
814
+ started_at=started_at,
815
+ policy=policy,
816
+ )
817
+ self._record_execution(
818
+ result,
819
+ operation,
820
+ targets,
821
+ entrypoint,
822
+ tuple(files),
823
+ requested_command,
824
+ )
825
+ return result
826
+ finally:
827
+ if instance.exists():
828
+ shutil.rmtree(instance)
829
+ with suppress(OSError):
830
+ job_parent.rmdir()
831
+
832
+ def _result(
833
+ self,
834
+ raw: BackendResult,
835
+ *,
836
+ command: Sequence[str],
837
+ cwd: Path,
838
+ execution_id: str,
839
+ request_digest: str,
840
+ source_digest: str,
841
+ started_at: str,
842
+ policy: ExecutionPolicy,
843
+ ) -> ExecutionResult:
844
+ combined = "\n".join(part for part in (raw.stdout, raw.stderr) if part)
845
+ diagnostics = parse_diagnostics(combined)
846
+ if raw.timed_out:
847
+ diagnostics += (error_diagnostic("Lean execution exceeded its time limit"),)
848
+ if raw.cancelled:
849
+ diagnostics += (error_diagnostic("Lean execution was cancelled"),)
850
+ provenance = ExecutionProvenance(
851
+ environment_id=self.id,
852
+ execution_id=execution_id,
853
+ request_digest=request_digest,
854
+ lock_id=self.lock.lock_id,
855
+ toolchain=self.lock.toolchain,
856
+ packages=tuple(
857
+ PackageProvenance(package.name, package.url, package.revision, package.tree_hash)
858
+ for package in self.lock.packages
859
+ ),
860
+ platform=platform_record(),
861
+ backend=self.manager.backend.name,
862
+ requested_policy=policy.to_dict(),
863
+ enforced_policy_fields=raw.enforced_policy_fields,
864
+ source_digest=source_digest,
865
+ started_at=started_at,
866
+ )
867
+ return ExecutionResult(
868
+ ok=raw.exit_code == 0,
869
+ exit_code=raw.exit_code,
870
+ toolchain=self.lock.toolchain,
871
+ command=tuple(command),
872
+ cwd=str(cwd),
873
+ stdout=raw.stdout,
874
+ stderr=raw.stderr,
875
+ elapsed_seconds=raw.elapsed_seconds,
876
+ timed_out=raw.timed_out,
877
+ cancelled=raw.cancelled,
878
+ output_truncated=raw.output_truncated,
879
+ diagnostics=diagnostics,
880
+ provenance=provenance,
881
+ )
882
+
883
+ def _record_execution(
884
+ self,
885
+ result: ExecutionResult,
886
+ operation: str,
887
+ targets: tuple[str, ...],
888
+ entrypoint: str | None,
889
+ files: tuple[str, ...],
890
+ requested_command: tuple[str, ...],
891
+ ) -> None:
892
+ assert result.execution_id is not None
893
+ write_json_atomic(
894
+ self.manager.store.executions / f"{result.execution_id}.json",
895
+ {
896
+ "schema": EXECUTION_SCHEMA,
897
+ "operation": operation,
898
+ "targets": list(targets),
899
+ "entrypoint": entrypoint,
900
+ "files": list(files),
901
+ "command": list(requested_command),
902
+ "result": result.to_dict(),
903
+ },
904
+ )
905
+
906
+
907
+ class EnvironmentManager:
908
+ def __init__(
909
+ self,
910
+ store: EnvironmentStore,
911
+ toolchains: ToolchainManager,
912
+ backend: Backend,
913
+ events: EventEmitter | None = None,
914
+ ) -> None:
915
+ self.store = store
916
+ self.toolchains = toolchains
917
+ self.backend = backend
918
+ self.events = events or EventEmitter()
919
+
920
+ def ensure(
921
+ self,
922
+ lock: EnvironmentLock,
923
+ *,
924
+ name: str | None = None,
925
+ build_profile: str = "release",
926
+ ) -> Environment:
927
+ self.store.publish_lock(lock)
928
+ environment_id = environment_identity(lock, build_profile)
929
+ destination = self.store.environment_path(environment_id)
930
+ self.events.emit(
931
+ "environment.ensure_started",
932
+ "Ensuring published environment",
933
+ environment_id=environment_id,
934
+ )
935
+ with FileLock(self.store.lock_dir / f"{environment_id}.lock", timeout=1800):
936
+ if not destination.is_dir():
937
+ self._ensure_sources(lock)
938
+ self._materialize(lock, environment_id, destination, build_profile)
939
+ else:
940
+ self.events.emit(
941
+ "environment.cache_hit",
942
+ "Reusing published environment",
943
+ environment_id=environment_id,
944
+ )
945
+ environment = self.open(environment_id)
946
+ if name:
947
+ self.store.set_alias(name, environment_id)
948
+ self.events.emit(
949
+ "environment.ready",
950
+ "Environment is ready",
951
+ environment_id=environment_id,
952
+ name=name,
953
+ )
954
+ return environment
955
+
956
+ def open(self, identifier: str) -> Environment:
957
+ environment_id = self.store.resolve_identifier(identifier)
958
+ root = self.store.environment_path(environment_id)
959
+ record_path = root / "metadata.json"
960
+ if not record_path.is_file():
961
+ raise EnvironmentError(f"published environment has no metadata: {environment_id}")
962
+ record = json.loads(record_path.read_text(encoding="utf-8"))
963
+ if record.get("schema") != ENVIRONMENT_SCHEMA or record.get("status") != "ready":
964
+ raise EnvironmentError(f"environment is not ready: {environment_id}")
965
+ lock = self.store.load_lock(str(record["lock_id"]))
966
+ expected = environment_identity(lock, str(record["build_profile"]))
967
+ if expected != environment_id or record.get("environment_id") != environment_id:
968
+ raise EnvironmentError(f"environment identity mismatch: {environment_id}")
969
+ if not (root / "workspace" / ".lake" / "build").is_dir():
970
+ raise EnvironmentError(f"environment build artifacts are missing: {environment_id}")
971
+ self.store.touch_environment(environment_id)
972
+ return Environment(self, environment_id, lock, root, record)
973
+
974
+ def _materialize(
975
+ self,
976
+ lock: EnvironmentLock,
977
+ environment_id: str,
978
+ destination: Path,
979
+ build_profile: str,
980
+ ) -> None:
981
+ stage = self.store.environments / f".staging-{os.getpid()}-{uuid.uuid4().hex}"
982
+ workspace = stage / "workspace"
983
+ try:
984
+ self.events.emit(
985
+ "environment.build_started",
986
+ "Building environment",
987
+ environment_id=environment_id,
988
+ )
989
+ workspace.mkdir(parents=True)
990
+ (workspace / "lean-toolchain").write_text(lock.toolchain + "\n", encoding="utf-8")
991
+ (workspace / "lakefile.toml").write_text(lock.root_lakefile, encoding="utf-8")
992
+ (workspace / f"{ROOT_MODULE}.lean").write_text(lock.root_module, encoding="utf-8")
993
+ write_json_atomic(workspace / "lake-manifest.json", lock.manifest)
994
+ packages_dir = workspace / str(lock.manifest.get("packagesDir", ".lake/packages"))
995
+ packages_dir.mkdir(parents=True, exist_ok=True)
996
+ for package in lock.packages:
997
+ source = self.store.source_path(package.source_id)
998
+ if not source.is_dir():
999
+ raise MaterializationError(
1000
+ f"locked package source is unavailable: {package.name}",
1001
+ phase="acquisition",
1002
+ )
1003
+ clone_tree(source, packages_dir / package.name)
1004
+ build_policy = ExecutionPolicy(timeout_seconds=1800, max_output_bytes=10_000_000)
1005
+ hydration: list[dict[str, Any]] = []
1006
+ for package in lock.packages:
1007
+ if not package.artifact_command:
1008
+ continue
1009
+ self.events.emit(
1010
+ "artifact.hydration_started",
1011
+ f"Hydrating artifacts for {package.name}",
1012
+ package=package.name,
1013
+ )
1014
+ command = list(package.artifact_command)
1015
+ if command[0] in {"lake", "lean"}:
1016
+ command = self.toolchains.command(lock.toolchain, command[0], *command[1:])
1017
+ result = self.backend.execute(
1018
+ command,
1019
+ cwd=workspace,
1020
+ environment=self.toolchains.environment,
1021
+ policy=build_policy,
1022
+ )
1023
+ hydration.append(
1024
+ {
1025
+ "package": package.name,
1026
+ "command": command,
1027
+ "exit_code": result.exit_code,
1028
+ "output": result.stdout + result.stderr,
1029
+ }
1030
+ )
1031
+ if result.exit_code:
1032
+ raise MaterializationError(
1033
+ f"artifact hydration failed for {package.name}",
1034
+ phase="artifact-hydration",
1035
+ command=tuple(command),
1036
+ exit_code=result.exit_code,
1037
+ output=result.stdout + result.stderr,
1038
+ )
1039
+ command = self.toolchains.command(lock.toolchain, "lake", "build")
1040
+ result = self.backend.execute(
1041
+ command,
1042
+ cwd=workspace,
1043
+ environment=self.toolchains.environment,
1044
+ policy=build_policy,
1045
+ )
1046
+ if result.exit_code:
1047
+ raise MaterializationError(
1048
+ "environment build failed",
1049
+ phase="build",
1050
+ command=tuple(command),
1051
+ exit_code=result.exit_code,
1052
+ output=result.stdout + result.stderr,
1053
+ )
1054
+ metadata = {
1055
+ "schema": ENVIRONMENT_SCHEMA,
1056
+ "environment_id": environment_id,
1057
+ "lock_id": lock.lock_id,
1058
+ "toolchain": lock.toolchain,
1059
+ "platform": platform_record(),
1060
+ "build_profile": build_profile,
1061
+ "status": "ready",
1062
+ "created_at": _now(),
1063
+ "hydration": hydration,
1064
+ "build": {
1065
+ "command": command,
1066
+ "exit_code": result.exit_code,
1067
+ "elapsed_seconds": result.elapsed_seconds,
1068
+ "output_truncated": result.output_truncated,
1069
+ },
1070
+ }
1071
+ write_json_atomic(stage / "metadata.json", metadata)
1072
+ stage.replace(destination)
1073
+ self.events.emit(
1074
+ "environment.published",
1075
+ "Published built environment",
1076
+ environment_id=environment_id,
1077
+ )
1078
+ finally:
1079
+ if stage.exists():
1080
+ shutil.rmtree(stage)
1081
+
1082
+ def _ensure_sources(self, lock: EnvironmentLock) -> None:
1083
+ """Acquire exact locked sources without invoking Lake resolution."""
1084
+ acquisition_root = self.store.home / "acquisition"
1085
+ acquisition_root.mkdir(parents=True, exist_ok=True)
1086
+ for package in lock.packages:
1087
+ if self.store.source_path(package.source_id).is_dir():
1088
+ self.store.validate_source(
1089
+ package.source_id,
1090
+ url=package.url,
1091
+ revision=package.revision,
1092
+ tree_hash=package.tree_hash,
1093
+ )
1094
+ self.events.emit(
1095
+ "source.cache_hit",
1096
+ f"Reusing source for {package.name}",
1097
+ package=package.name,
1098
+ source_id=package.source_id,
1099
+ )
1100
+ continue
1101
+ self.events.emit(
1102
+ "source.fetch_started",
1103
+ f"Fetching {package.name}",
1104
+ package=package.name,
1105
+ revision=package.revision,
1106
+ )
1107
+ with tempfile.TemporaryDirectory(
1108
+ prefix=f"{package.name}-", dir=acquisition_root
1109
+ ) as raw:
1110
+ checkout = Path(raw)
1111
+ commands = [
1112
+ ["git", "init", "-q"],
1113
+ ["git", "remote", "add", "origin", package.url],
1114
+ [
1115
+ "git",
1116
+ "fetch",
1117
+ "--depth",
1118
+ "1",
1119
+ "--filter=blob:none",
1120
+ "origin",
1121
+ package.revision,
1122
+ ],
1123
+ ["git", "checkout", "--detach", "FETCH_HEAD"],
1124
+ ]
1125
+ for command in commands:
1126
+ completed = subprocess.run(
1127
+ command,
1128
+ cwd=checkout,
1129
+ text=True,
1130
+ capture_output=True,
1131
+ check=False,
1132
+ )
1133
+ if completed.returncode:
1134
+ raise MaterializationError(
1135
+ f"could not acquire locked source {package.name}",
1136
+ phase="acquisition",
1137
+ command=tuple(command),
1138
+ exit_code=completed.returncode,
1139
+ output=completed.stdout + completed.stderr,
1140
+ )
1141
+ tree = subprocess.run(
1142
+ ["git", "rev-parse", "HEAD^{tree}"],
1143
+ cwd=checkout,
1144
+ text=True,
1145
+ capture_output=True,
1146
+ check=True,
1147
+ ).stdout.strip()
1148
+ if tree != package.tree_hash:
1149
+ raise MaterializationError(
1150
+ f"acquired Git tree does not match lock for {package.name}",
1151
+ phase="source-validation",
1152
+ )
1153
+ self.store.publish_source(
1154
+ checkout,
1155
+ package.source_id,
1156
+ {
1157
+ "schema": "lean-runtime-git-source/1",
1158
+ "source_id": package.source_id,
1159
+ "name": package.name,
1160
+ "url": package.url,
1161
+ "revision": package.revision,
1162
+ "tree_hash": package.tree_hash,
1163
+ },
1164
+ )
1165
+ self.store.validate_source(
1166
+ package.source_id,
1167
+ url=package.url,
1168
+ revision=package.revision,
1169
+ tree_hash=package.tree_hash,
1170
+ )