puremethod 0.1.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.
- app/__init__.py +2 -0
- app/__main__.py +31 -0
- app/_web/404/index.html +1 -0
- app/_web/404.html +1 -0
- app/_web/__next.!KGNvbnNvbGUp.__PAGE__.txt +42 -0
- app/_web/__next._full.txt +36 -0
- app/_web/__next._tree.txt +4 -0
- app/_web/_next/static/6az3RZxafySyERvJdZAgV/_buildManifest.js +11 -0
- app/_web/_next/static/6az3RZxafySyERvJdZAgV/_clientMiddlewareManifest.js +1 -0
- app/_web/_next/static/6az3RZxafySyERvJdZAgV/_ssgManifest.js +1 -0
- app/_web/_next/static/chunks/00fylloju-1x2.js +1 -0
- app/_web/_next/static/chunks/03hxwfej4sm9g.js +1 -0
- app/_web/_next/static/chunks/0cz1d0mv5g_q7.js +1 -0
- app/_web/_next/static/chunks/0gus9jro9n3j3.js +1 -0
- app/_web/_next/static/chunks/0n12a63ieedt9.js +1 -0
- app/_web/_next/static/chunks/0ns7paypbayry.js +31 -0
- app/_web/_next/static/chunks/0s1d7y-_wn8wn.js +1 -0
- app/_web/_next/static/chunks/12s40azz56b3b.js +1 -0
- app/_web/_next/static/chunks/1b_14wnek5geq.js +1 -0
- app/_web/_next/static/chunks/1oderptqhsaox.css +1 -0
- app/_web/_next/static/chunks/1rcnm-dleh7p6.js +1 -0
- app/_web/_next/static/chunks/1u4klctqx_w0w.js +1 -0
- app/_web/_next/static/chunks/1vahkxwiu_bqp.js +22 -0
- app/_web/_next/static/chunks/26mp4u1wew2xu.js +1 -0
- app/_web/_next/static/chunks/2aq880yo-2z88.js +1 -0
- app/_web/_next/static/chunks/2f5ovhdilzkal.js +1 -0
- app/_web/_next/static/chunks/2qds0qd18u74f.js +1 -0
- app/_web/_next/static/chunks/2qgmxvbiean_r.js +1 -0
- app/_web/_next/static/chunks/3b5v1mle2gmac.js +1 -0
- app/_web/_next/static/chunks/3hw8x3h98ysh4.js +6 -0
- app/_web/_next/static/chunks/3op5__grw4752.js +1 -0
- app/_web/_next/static/chunks/3scuzzds0vtp7.js +1 -0
- app/_web/_next/static/chunks/3v_5wdpvvcxz8.js +1 -0
- app/_web/_next/static/chunks/3wevuf_oyh97l.js +1 -0
- app/_web/_next/static/chunks/turbopack-3-ry682tkyiqk.js +1 -0
- app/_web/_not-found/__next._full.txt +24 -0
- app/_web/_not-found/__next._not-found.__PAGE__.txt +30 -0
- app/_web/_not-found/__next._tree.txt +4 -0
- app/_web/_not-found/index.html +1 -0
- app/_web/_not-found/index.txt +24 -0
- app/_web/credentials/__next.!KGNvbnNvbGUp.credentials.__PAGE__.txt +46 -0
- app/_web/credentials/__next._full.txt +39 -0
- app/_web/credentials/__next._tree.txt +5 -0
- app/_web/credentials/index.html +1 -0
- app/_web/credentials/index.txt +39 -0
- app/_web/executors/__next.!KGNvbnNvbGUp.executors.__PAGE__.txt +46 -0
- app/_web/executors/__next._full.txt +39 -0
- app/_web/executors/__next._tree.txt +5 -0
- app/_web/executors/index.html +1 -0
- app/_web/executors/index.txt +39 -0
- app/_web/index.html +1 -0
- app/_web/index.txt +36 -0
- app/_web/new/__next.!KGNvbnNvbGUp.new.__PAGE__.txt +46 -0
- app/_web/new/__next._full.txt +39 -0
- app/_web/new/__next._tree.txt +5 -0
- app/_web/new/index.html +1 -0
- app/_web/new/index.txt +39 -0
- app/_web/profiles/__next.!KGNvbnNvbGUp.profiles.__PAGE__.txt +46 -0
- app/_web/profiles/__next._full.txt +39 -0
- app/_web/profiles/__next._tree.txt +5 -0
- app/_web/profiles/index.html +1 -0
- app/_web/profiles/index.txt +39 -0
- app/_web/run/__next.!KGNvbnNvbGUp.run.__PAGE__.txt +47 -0
- app/_web/run/__next._full.txt +40 -0
- app/_web/run/__next._tree.txt +5 -0
- app/_web/run/index.html +1 -0
- app/_web/run/index.txt +40 -0
- app/_web/services/__next.!KGNvbnNvbGUp.services.__PAGE__.txt +46 -0
- app/_web/services/__next._full.txt +39 -0
- app/_web/services/__next._tree.txt +5 -0
- app/_web/services/index.html +1 -0
- app/_web/services/index.txt +39 -0
- app/_web/setup/__next.!KGNvbnNvbGUp.setup.__PAGE__.txt +46 -0
- app/_web/setup/__next._full.txt +39 -0
- app/_web/setup/__next._tree.txt +5 -0
- app/_web/setup/index.html +1 -0
- app/_web/setup/index.txt +39 -0
- app/_web/signin/__next._full.txt +30 -0
- app/_web/signin/__next._tree.txt +4 -0
- app/_web/signin/__next.signin.__PAGE__.txt +36 -0
- app/_web/signin/index.html +1 -0
- app/_web/signin/index.txt +30 -0
- app/accounts.py +137 -0
- app/addresses.py +30 -0
- app/api/__init__.py +61 -0
- app/api/auth_routes.py +54 -0
- app/api/common.py +102 -0
- app/api/config_routes.py +562 -0
- app/api/links.py +22 -0
- app/api/run_routes.py +311 -0
- app/api/setup_routes.py +267 -0
- app/bundled.py +521 -0
- app/compose.py +229 -0
- app/db.py +186 -0
- app/executor_config.py +95 -0
- app/keys.py +29 -0
- app/launcher.py +389 -0
- app/leases.py +40 -0
- app/literature_sources.py +131 -0
- app/model_endpoints.py +104 -0
- app/model_search.py +252 -0
- app/outputs.py +77 -0
- app/paths.py +93 -0
- app/readonly.py +70 -0
- app/repo.py +746 -0
- app/scan.py +65 -0
- app/server.py +24 -0
- app/settings.py +125 -0
- app/setup.py +117 -0
- app/ssh_chain.py +102 -0
- app/ssh_hosts.py +244 -0
- app/ssh_machines.py +101 -0
- app/statuses.py +4 -0
- app/supervisor.py +87 -0
- app/tunnels.py +222 -0
- app/unseal.py +27 -0
- app/worker.py +657 -0
- executor/__init__.py +47 -0
- executor/base.py +161 -0
- executor/common.py +139 -0
- executor/local_docker.py +239 -0
- executor/local_process.py +247 -0
- executor/ssh.py +481 -0
- executor/types.py +128 -0
- executor/watchdog.py +108 -0
- ledger/__init__.py +46 -0
- ledger/artifacts.py +62 -0
- ledger/canonical.py +48 -0
- ledger/core.py +337 -0
- ledger/errors.py +36 -0
- ledger/model.py +107 -0
- ledger/payloads.py +228 -0
- ledger/rules.py +204 -0
- ledger/storage.py +226 -0
- llm/__init__.py +45 -0
- llm/backends/__init__.py +0 -0
- llm/backends/anthropic_backend.py +145 -0
- llm/backends/base.py +87 -0
- llm/backends/openai_backend.py +211 -0
- llm/core.py +416 -0
- llm/prices.py +98 -0
- llm/tokens.py +18 -0
- llm/types.py +106 -0
- loop/__init__.py +19 -0
- loop/config.py +108 -0
- loop/core.py +1160 -0
- loop/decide.py +61 -0
- loop/harness.py +132 -0
- loop/prompts.py +88 -0
- loop/view.py +202 -0
- puremethod-0.1.0.dist-info/METADATA +79 -0
- puremethod-0.1.0.dist-info/RECORD +171 -0
- puremethod-0.1.0.dist-info/WHEEL +4 -0
- puremethod-0.1.0.dist-info/entry_points.txt +2 -0
- puremethod-0.1.0.dist-info/licenses/LICENSE +96 -0
- puremethod-0.1.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +173 -0
- purescholar/__init__.py +43 -0
- purescholar/client.py +380 -0
- purescholar/types.py +164 -0
- puretrace/__init__.py +49 -0
- puretrace/client.py +320 -0
- puretrace/types.py +166 -0
- writer/__init__.py +8 -0
- writer/core.py +430 -0
- writer/facts.py +162 -0
- writer/latex.py +62 -0
- writer/lint.py +85 -0
- writer/macros.py +114 -0
- writer/render.py +120 -0
- writer/tectonic.py +79 -0
- writer/types.py +61 -0
executor/__init__.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""executor: runs experiment code on a backend the user chooses
|
|
2
|
+
(docs/knowledge_base/05-executor.md). v1 backends: local-docker, local-process, ssh."""
|
|
3
|
+
|
|
4
|
+
from .base import CredentialLookup, Executor
|
|
5
|
+
from .local_docker import LocalDockerExecutor
|
|
6
|
+
from .local_process import LocalProcessExecutor
|
|
7
|
+
from .ssh import SSHExecutor
|
|
8
|
+
from .types import (
|
|
9
|
+
DEFAULT_IMAGE,
|
|
10
|
+
METRICS_FILE,
|
|
11
|
+
DataMount,
|
|
12
|
+
EnvRef,
|
|
13
|
+
EnvSpec,
|
|
14
|
+
ExecutorError,
|
|
15
|
+
ExecutorInfo,
|
|
16
|
+
JobHandle,
|
|
17
|
+
JobResult,
|
|
18
|
+
JobSpec,
|
|
19
|
+
JobState,
|
|
20
|
+
NotDone,
|
|
21
|
+
PreflightReport,
|
|
22
|
+
Resources,
|
|
23
|
+
UnknownJob,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"DEFAULT_IMAGE",
|
|
28
|
+
"METRICS_FILE",
|
|
29
|
+
"CredentialLookup",
|
|
30
|
+
"DataMount",
|
|
31
|
+
"EnvRef",
|
|
32
|
+
"EnvSpec",
|
|
33
|
+
"Executor",
|
|
34
|
+
"ExecutorError",
|
|
35
|
+
"ExecutorInfo",
|
|
36
|
+
"JobHandle",
|
|
37
|
+
"JobResult",
|
|
38
|
+
"JobSpec",
|
|
39
|
+
"JobState",
|
|
40
|
+
"LocalDockerExecutor",
|
|
41
|
+
"LocalProcessExecutor",
|
|
42
|
+
"NotDone",
|
|
43
|
+
"PreflightReport",
|
|
44
|
+
"Resources",
|
|
45
|
+
"SSHExecutor",
|
|
46
|
+
"UnknownJob",
|
|
47
|
+
]
|
executor/base.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Shared executor behaviour: handles, queueing beyond the concurrency limit (§5 step 8),
|
|
2
|
+
and the `poll` / `result` / `kill` contract. Backends implement the `_` hooks."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import uuid
|
|
7
|
+
from abc import ABC, abstractmethod
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from .common import check_tag, redact, sanitized_tar
|
|
13
|
+
from .types import (
|
|
14
|
+
EnvRef,
|
|
15
|
+
EnvSpec,
|
|
16
|
+
ExecutorInfo,
|
|
17
|
+
JobHandle,
|
|
18
|
+
JobResult,
|
|
19
|
+
JobSpec,
|
|
20
|
+
JobState,
|
|
21
|
+
NotDone,
|
|
22
|
+
PreflightReport,
|
|
23
|
+
Status,
|
|
24
|
+
UnknownJob,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
CredentialLookup = Callable[[str], str]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class _Job:
|
|
32
|
+
spec: JobSpec
|
|
33
|
+
state: JobState = "queued"
|
|
34
|
+
backend: Any = None
|
|
35
|
+
killed: bool = False
|
|
36
|
+
result: JobResult | None = None
|
|
37
|
+
secrets: dict[str, str] = field(default_factory=dict)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class Executor(ABC):
|
|
41
|
+
"""One per configured backend, built from its configuration and a credential lookup
|
|
42
|
+
provided by the caller (05-executor.md §3, KB-015)."""
|
|
43
|
+
|
|
44
|
+
def __init__(self, credentials: CredentialLookup) -> None:
|
|
45
|
+
self._credentials = credentials
|
|
46
|
+
self._jobs: dict[str, _Job] = {}
|
|
47
|
+
self._charged_envs: set[str] = set()
|
|
48
|
+
|
|
49
|
+
# ---- interface ---------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
@abstractmethod
|
|
52
|
+
def describe(self) -> ExecutorInfo: ...
|
|
53
|
+
|
|
54
|
+
@abstractmethod
|
|
55
|
+
def preflight(self) -> PreflightReport: ...
|
|
56
|
+
|
|
57
|
+
@abstractmethod
|
|
58
|
+
def build_env(self, spec: EnvSpec) -> EnvRef: ...
|
|
59
|
+
|
|
60
|
+
@abstractmethod
|
|
61
|
+
def reap(self, tags: list[str]) -> list[str]:
|
|
62
|
+
"""Stops and removes every job, container and workspace carrying one of these
|
|
63
|
+
tags, whether or not this instance started it; returns the tags found (INV-9)."""
|
|
64
|
+
|
|
65
|
+
def submit(self, job: JobSpec) -> JobHandle:
|
|
66
|
+
if job.env.status != "ok":
|
|
67
|
+
raise ValueError("the job's environment failed to build")
|
|
68
|
+
if job.deadline_s <= 0:
|
|
69
|
+
raise ValueError("deadline_s must be positive")
|
|
70
|
+
sanitized_tar(job.code) # refuse an unsafe bundle now, not when it is dequeued
|
|
71
|
+
if job.tag is not None:
|
|
72
|
+
check_tag(job.tag)
|
|
73
|
+
secrets = {name: self._credentials(name) for name in job.credentials}
|
|
74
|
+
job_id = uuid.uuid4().hex[:16]
|
|
75
|
+
self._jobs[job_id] = _Job(spec=job, secrets=secrets)
|
|
76
|
+
self._start_queued()
|
|
77
|
+
return JobHandle(job_id=job_id, executor_id=self.describe().id)
|
|
78
|
+
|
|
79
|
+
def poll(self, handle: JobHandle) -> JobState:
|
|
80
|
+
job = self._job(handle)
|
|
81
|
+
if job.state == "running" and self._done(job.backend):
|
|
82
|
+
job.result = self._finish(handle.job_id, job)
|
|
83
|
+
job.state = "done"
|
|
84
|
+
self._start_queued()
|
|
85
|
+
return job.state
|
|
86
|
+
|
|
87
|
+
def result(self, handle: JobHandle) -> JobResult:
|
|
88
|
+
job = self._job(handle)
|
|
89
|
+
if job.state != "done" or job.result is None:
|
|
90
|
+
raise NotDone(handle.job_id)
|
|
91
|
+
return job.result
|
|
92
|
+
|
|
93
|
+
def kill(self, handle: JobHandle) -> None:
|
|
94
|
+
job = self._job(handle)
|
|
95
|
+
if job.state == "done":
|
|
96
|
+
return
|
|
97
|
+
job.killed = True
|
|
98
|
+
if job.state == "queued":
|
|
99
|
+
job.result = self._never_started(job)
|
|
100
|
+
job.state = "done"
|
|
101
|
+
return
|
|
102
|
+
self._kill(job.backend)
|
|
103
|
+
|
|
104
|
+
# ---- internals ---------------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
def _job(self, handle: JobHandle) -> _Job:
|
|
107
|
+
job = self._jobs.get(handle.job_id)
|
|
108
|
+
if job is None:
|
|
109
|
+
raise UnknownJob(handle.job_id)
|
|
110
|
+
return job
|
|
111
|
+
|
|
112
|
+
def _start_queued(self) -> None:
|
|
113
|
+
running = sum(1 for j in self._jobs.values() if j.state == "running")
|
|
114
|
+
for job_id, job in self._jobs.items():
|
|
115
|
+
if running >= self.describe().concurrency:
|
|
116
|
+
break
|
|
117
|
+
if job.state == "queued" and not job.killed:
|
|
118
|
+
try:
|
|
119
|
+
job.backend = self._start(job_id, job.spec, job.secrets)
|
|
120
|
+
except Exception as exc: # a backend fault ends this job, not the queue
|
|
121
|
+
job.result = self._never_started(job, status="crashed",
|
|
122
|
+
reason=f"start failed: "
|
|
123
|
+
f"{type(exc).__name__}: {exc}")
|
|
124
|
+
job.state = "done"
|
|
125
|
+
continue
|
|
126
|
+
job.state = "running"
|
|
127
|
+
running += 1
|
|
128
|
+
|
|
129
|
+
def _finish(self, job_id: str, job: _Job) -> JobResult:
|
|
130
|
+
result = self._collect(job.backend, job.spec, job.secrets, killed=job.killed)
|
|
131
|
+
env = job.spec.env
|
|
132
|
+
if env.built and env.digest not in self._charged_envs:
|
|
133
|
+
# §5 step 6: a fresh environment build is charged to the first job using it.
|
|
134
|
+
self._charged_envs.add(env.digest)
|
|
135
|
+
rate = self.describe().rate_usd_per_hour
|
|
136
|
+
if result.cost_usd is not None and rate is not None:
|
|
137
|
+
result.cost_usd += rate * env.build_seconds / 3600.0
|
|
138
|
+
return result
|
|
139
|
+
|
|
140
|
+
def _never_started(self, job: _Job, status: Status = "killed",
|
|
141
|
+
reason: str = "") -> JobResult:
|
|
142
|
+
stderr, _ = redact(reason.encode(), job.secrets)
|
|
143
|
+
return JobResult(status=status, exit_code=None, stdout=b"", stderr=stderr, outputs={},
|
|
144
|
+
metrics=None, started_at=None, ended_at=None, duration_s=0.0,
|
|
145
|
+
cost_usd=0.0 if self.describe().metered else None,
|
|
146
|
+
metered=self.describe().metered, env_digest=job.spec.env.digest,
|
|
147
|
+
code_digest=job.spec.code_digest, redactions=[],
|
|
148
|
+
output_cap_bytes=0)
|
|
149
|
+
|
|
150
|
+
@abstractmethod
|
|
151
|
+
def _start(self, job_id: str, spec: JobSpec, secrets: dict[str, str]) -> Any: ...
|
|
152
|
+
|
|
153
|
+
@abstractmethod
|
|
154
|
+
def _done(self, backend: Any) -> bool: ...
|
|
155
|
+
|
|
156
|
+
@abstractmethod
|
|
157
|
+
def _collect(self, backend: Any, spec: JobSpec, secrets: dict[str, str], *,
|
|
158
|
+
killed: bool) -> JobResult: ...
|
|
159
|
+
|
|
160
|
+
@abstractmethod
|
|
161
|
+
def _kill(self, backend: Any) -> None: ...
|
executor/common.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Helpers shared by the backends: workspaces, redaction, outputs, status, cost."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import io
|
|
6
|
+
import os
|
|
7
|
+
import tarfile
|
|
8
|
+
from datetime import UTC, datetime
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from .types import Status
|
|
12
|
+
|
|
13
|
+
TAG_FILE = "pm_tag" # in the job directory, outside the workspace the job can write
|
|
14
|
+
_TAG_OK = frozenset("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_.:-")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def check_tag(tag: str) -> str:
|
|
18
|
+
"""Tags travel into container labels and shell commands: a closed character set."""
|
|
19
|
+
if not tag or len(tag) > 200 or not set(tag) <= _TAG_OK:
|
|
20
|
+
raise ValueError(f"invalid job tag {tag!r}")
|
|
21
|
+
return tag
|
|
22
|
+
|
|
23
|
+
OUTPUT_CAP = 2 * 1024 * 1024 # bytes kept per captured stream or text output
|
|
24
|
+
EXIT_TIMEOUT = 124 # coreutils/busybox `timeout` when the deadline passes
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def now_iso() -> str:
|
|
28
|
+
return datetime.now(UTC).isoformat(timespec="milliseconds").replace("+00:00", "Z")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def unpack(code: bytes, dest: Path) -> None:
|
|
32
|
+
"""Unpack a code bundle with the `data` filter: no absolute paths, no `..`, no links
|
|
33
|
+
out of the workspace, no device files (§5 step 1)."""
|
|
34
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
35
|
+
try:
|
|
36
|
+
with tarfile.open(fileobj=io.BytesIO(code), mode="r:*") as tar:
|
|
37
|
+
tar.extractall(dest, filter="data")
|
|
38
|
+
except (tarfile.FilterError, tarfile.ReadError) as exc:
|
|
39
|
+
raise ValueError(f"unsafe or unreadable code bundle: {type(exc).__name__}") from None
|
|
40
|
+
(dest / "outputs").mkdir(exist_ok=True)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def sanitized_tar(code: bytes) -> bytes:
|
|
44
|
+
"""Re-pack a bundle after the same checks, for hosts whose `tar` may be less strict."""
|
|
45
|
+
out = io.BytesIO()
|
|
46
|
+
try:
|
|
47
|
+
with tarfile.open(fileobj=io.BytesIO(code), mode="r:*") as src, \
|
|
48
|
+
tarfile.open(fileobj=out, mode="w") as dst:
|
|
49
|
+
for member in src.getmembers():
|
|
50
|
+
safe = tarfile.data_filter(member, "/nonexistent-root")
|
|
51
|
+
# The filter clears ownership ("don't apply"); a new archive needs values.
|
|
52
|
+
safe = safe.replace(uid=0, gid=0, uname="", gname="", deep=False)
|
|
53
|
+
if safe.isfile():
|
|
54
|
+
dst.addfile(safe, src.extractfile(member))
|
|
55
|
+
elif safe.isdir():
|
|
56
|
+
dst.addfile(safe)
|
|
57
|
+
except (tarfile.FilterError, tarfile.ReadError) as exc:
|
|
58
|
+
raise ValueError(f"unsafe or unreadable code bundle: {type(exc).__name__}") from None
|
|
59
|
+
return out.getvalue()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def redact(data: bytes, secrets: dict[str, str]) -> tuple[bytes, list[str]]:
|
|
63
|
+
"""Replace every value of the job's credentials (§5 step 5). Returns names, never values."""
|
|
64
|
+
hit: list[str] = []
|
|
65
|
+
for name, value in secrets.items():
|
|
66
|
+
if value and value.encode() in data:
|
|
67
|
+
data = data.replace(value.encode(), f"[REDACTED:{name}]".encode())
|
|
68
|
+
hit.append(name)
|
|
69
|
+
return data, hit
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def cap(data: bytes, limit: int = OUTPUT_CAP) -> tuple[bytes, bool]:
|
|
73
|
+
return (data[:limit], True) if len(data) > limit else (data, False)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def read_outputs(outputs_dir: Path, secrets: dict[str, str]
|
|
77
|
+
) -> tuple[dict[str, bytes], list[str], list[str]]:
|
|
78
|
+
"""Files under `outputs/`, with text files redacted and capped."""
|
|
79
|
+
files: dict[str, bytes] = {}
|
|
80
|
+
redacted: list[str] = []
|
|
81
|
+
truncated: list[str] = []
|
|
82
|
+
if not outputs_dir.is_dir():
|
|
83
|
+
return files, redacted, truncated
|
|
84
|
+
for path in sorted(outputs_dir.rglob("*")):
|
|
85
|
+
if not path.is_file() or path.is_symlink():
|
|
86
|
+
continue
|
|
87
|
+
rel = "outputs/" + path.relative_to(outputs_dir).as_posix()
|
|
88
|
+
data = path.read_bytes()
|
|
89
|
+
files[rel], names, cut = process_text(data, secrets)
|
|
90
|
+
redacted += names
|
|
91
|
+
if cut:
|
|
92
|
+
truncated.append(rel)
|
|
93
|
+
return files, sorted(set(redacted)), truncated
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def process_text(data: bytes, secrets: dict[str, str]) -> tuple[bytes, list[str], bool]:
|
|
97
|
+
"""Redact and cap a file if it is text; binary files pass through unchanged."""
|
|
98
|
+
try:
|
|
99
|
+
data.decode("utf-8")
|
|
100
|
+
except UnicodeDecodeError:
|
|
101
|
+
return data, [], False
|
|
102
|
+
data, names = redact(data, secrets)
|
|
103
|
+
data, cut = cap(data)
|
|
104
|
+
return data, names, cut
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def classify(exit_code: int | None, *, killed: bool, timed_out: bool,
|
|
108
|
+
crashed: bool) -> Status:
|
|
109
|
+
"""§4 statuses. A kill by the user wins; then the deadline; then crashes."""
|
|
110
|
+
if killed:
|
|
111
|
+
return "killed"
|
|
112
|
+
if timed_out or exit_code == EXIT_TIMEOUT:
|
|
113
|
+
return "timeout"
|
|
114
|
+
if crashed:
|
|
115
|
+
return "crashed"
|
|
116
|
+
if exit_code is None:
|
|
117
|
+
return "lost"
|
|
118
|
+
if exit_code == 0:
|
|
119
|
+
return "ok"
|
|
120
|
+
if exit_code < 0 or exit_code in (137, 139, 143): # ended by a signal
|
|
121
|
+
return "crashed"
|
|
122
|
+
return "failed"
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def cost(rate_usd_per_hour: float | None, seconds: float | None) -> tuple[float | None, bool]:
|
|
126
|
+
"""§5 step 6: rate times wall-clock, or unknown and unmetered. Never a default 0."""
|
|
127
|
+
if rate_usd_per_hour is None or seconds is None:
|
|
128
|
+
return None, False
|
|
129
|
+
return rate_usd_per_hour * seconds / 3600.0, True
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def minimal_env(extra: dict[str, str]) -> dict[str, str]:
|
|
133
|
+
"""A small environment for agent-written code. The engine's own environment (which may
|
|
134
|
+
hold provider keys) is never inherited."""
|
|
135
|
+
keep = ("PATH", "SYSTEMROOT", "SystemRoot", "WINDIR", "COMSPEC", "PATHEXT", "TEMP", "TMP",
|
|
136
|
+
"LANG", "LC_ALL", "TZ")
|
|
137
|
+
env = {k: os.environ[k] for k in keep if k in os.environ}
|
|
138
|
+
env.update(extra)
|
|
139
|
+
return env
|
executor/local_docker.py
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
"""`local-docker` backend: isolation `container` (05-executor.md §5, §6).
|
|
2
|
+
|
|
3
|
+
The container sees only its workspace (read-write) and the configured data mounts
|
|
4
|
+
(read-only); no host home directory, Docker socket or host credentials (§5 step 2). The
|
|
5
|
+
deadline is enforced inside the container by `timeout`, so it holds without the engine
|
|
6
|
+
(INV-2). Credentials are passed as `-e NAME` with the value in the Docker CLI's own
|
|
7
|
+
environment, never on its command line or on disk (INV-7).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import shutil
|
|
15
|
+
import subprocess
|
|
16
|
+
import tempfile
|
|
17
|
+
import time
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any, Literal
|
|
21
|
+
|
|
22
|
+
from .base import CredentialLookup, Executor
|
|
23
|
+
from .common import TAG_FILE, cap, check_tag, cost, minimal_env, read_outputs, redact, unpack
|
|
24
|
+
from .common import classify as classify_status
|
|
25
|
+
from .local_process import _tar
|
|
26
|
+
from .types import (
|
|
27
|
+
DEFAULT_IMAGE,
|
|
28
|
+
METRICS_FILE,
|
|
29
|
+
EnvRef,
|
|
30
|
+
EnvSpec,
|
|
31
|
+
ExecutorInfo,
|
|
32
|
+
JobResult,
|
|
33
|
+
JobSpec,
|
|
34
|
+
Network,
|
|
35
|
+
PreflightReport,
|
|
36
|
+
Resources,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
LABEL = "puremethod.job"
|
|
40
|
+
TAG_LABEL = "puremethod.tag"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class _Container:
|
|
45
|
+
name: str
|
|
46
|
+
ws: Path
|
|
47
|
+
deadline_s: float
|
|
48
|
+
started: float
|
|
49
|
+
killed: bool = False
|
|
50
|
+
info: dict[str, Any] = field(default_factory=dict)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class LocalDockerExecutor(Executor):
|
|
54
|
+
def __init__(self, credentials: CredentialLookup, *, executor_id: str = "local-docker",
|
|
55
|
+
root: str | Path, concurrency: int = 1,
|
|
56
|
+
rate_usd_per_hour: float | None = None, network: Network = "on",
|
|
57
|
+
grace_s: float = 30.0, resources: Resources | None = None,
|
|
58
|
+
docker: str = "docker") -> None:
|
|
59
|
+
super().__init__(credentials)
|
|
60
|
+
self._info = ExecutorInfo(id=executor_id, kind="local-docker", isolation="container",
|
|
61
|
+
network=network, concurrency=concurrency,
|
|
62
|
+
resources=resources or Resources(),
|
|
63
|
+
rate_usd_per_hour=rate_usd_per_hour)
|
|
64
|
+
self._root = Path(root)
|
|
65
|
+
self._grace = grace_s
|
|
66
|
+
self._docker = docker
|
|
67
|
+
|
|
68
|
+
def describe(self) -> ExecutorInfo:
|
|
69
|
+
return self._info
|
|
70
|
+
|
|
71
|
+
def _cli(self, *args: str, env: dict[str, str] | None = None, check: bool = True,
|
|
72
|
+
timeout: float = 120) -> subprocess.CompletedProcess[str]:
|
|
73
|
+
return subprocess.run([self._docker, *args], capture_output=True, text=True, # noqa: S603
|
|
74
|
+
check=check, env=env, timeout=timeout)
|
|
75
|
+
|
|
76
|
+
def preflight(self) -> PreflightReport:
|
|
77
|
+
try:
|
|
78
|
+
self._cli("version", "--format", "{{.Server.Version}}", timeout=30)
|
|
79
|
+
except (OSError, subprocess.SubprocessError) as exc:
|
|
80
|
+
return PreflightReport(False, [f"docker unavailable: {type(exc).__name__}"])
|
|
81
|
+
env = self.build_env(EnvSpec())
|
|
82
|
+
if env.status != "ok":
|
|
83
|
+
return PreflightReport(False, [f"environment: {env.log[-500:]}"])
|
|
84
|
+
handle = self.submit(JobSpec(code=_tar({"main.py": b"print('ok')\n"}),
|
|
85
|
+
entrypoint="python main.py", env=env, seed=0,
|
|
86
|
+
deadline_s=60))
|
|
87
|
+
deadline = time.monotonic() + 120
|
|
88
|
+
while self.poll(handle) != "done" and time.monotonic() < deadline:
|
|
89
|
+
time.sleep(0.5)
|
|
90
|
+
if self.poll(handle) != "done" or self.result(handle).status != "ok":
|
|
91
|
+
return PreflightReport(False, ["a trivial job did not run"])
|
|
92
|
+
return PreflightReport(True, [])
|
|
93
|
+
|
|
94
|
+
def build_env(self, spec: EnvSpec) -> EnvRef:
|
|
95
|
+
digest = spec.digest()
|
|
96
|
+
tag = "pm-env:" + digest.removeprefix("sha256:")[:24]
|
|
97
|
+
if self._cli("image", "inspect", tag, check=False).returncode == 0:
|
|
98
|
+
return EnvRef(digest=digest, status="ok", ref=tag)
|
|
99
|
+
lines = [f"FROM {spec.base_image or DEFAULT_IMAGE}"]
|
|
100
|
+
if spec.system_packages:
|
|
101
|
+
pkgs = " ".join(spec.system_packages)
|
|
102
|
+
lines.append("RUN apt-get update && apt-get install -y --no-install-recommends "
|
|
103
|
+
f"{pkgs} && rm -rf /var/lib/apt/lists/*")
|
|
104
|
+
if spec.requirements_lock.strip():
|
|
105
|
+
lines += ["COPY requirements.lock /tmp/requirements.lock",
|
|
106
|
+
"RUN pip install --no-cache-dir -r /tmp/requirements.lock"]
|
|
107
|
+
lines.append(f'LABEL puremethod.env="{digest}"')
|
|
108
|
+
t0 = time.monotonic()
|
|
109
|
+
with tempfile.TemporaryDirectory(prefix="pm-env-") as ctx:
|
|
110
|
+
Path(ctx, "Dockerfile").write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
111
|
+
Path(ctx, "requirements.lock").write_text(spec.requirements_lock, encoding="utf-8")
|
|
112
|
+
out = self._cli("build", "-t", tag, ctx, check=False, timeout=3600)
|
|
113
|
+
log = (out.stdout + out.stderr)[-20000:]
|
|
114
|
+
status: Literal["ok", "failed"] = "ok" if out.returncode == 0 else "failed"
|
|
115
|
+
return EnvRef(digest=digest, status=status, ref=tag if status == "ok" else "",
|
|
116
|
+
log=log, build_seconds=time.monotonic() - t0, built=status == "ok")
|
|
117
|
+
|
|
118
|
+
# ---- backend hooks -----------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
def _start(self, job_id: str, spec: JobSpec, secrets: dict[str, str]) -> _Container:
|
|
121
|
+
ws = (self._root / "jobs" / job_id / "ws").resolve()
|
|
122
|
+
unpack(spec.code, ws)
|
|
123
|
+
name = f"pm-job-{job_id}"
|
|
124
|
+
args = ["run", "-d", "--name", name, "--label", f"{LABEL}={job_id}",
|
|
125
|
+
"-v", f"{ws}:/work", "-w", "/work", "-e", f"PM_SEED={spec.seed}"]
|
|
126
|
+
if spec.tag is not None:
|
|
127
|
+
(ws.parent / TAG_FILE).write_text(spec.tag, encoding="utf-8")
|
|
128
|
+
args += ["--label", f"{TAG_LABEL}={spec.tag}"]
|
|
129
|
+
if self._info.network == "off":
|
|
130
|
+
args += ["--network", "none"]
|
|
131
|
+
r = spec.resources
|
|
132
|
+
if r.cpus:
|
|
133
|
+
args += ["--cpus", str(r.cpus)]
|
|
134
|
+
if r.memory_mb:
|
|
135
|
+
args += ["--memory", f"{r.memory_mb}m"]
|
|
136
|
+
if r.gpus:
|
|
137
|
+
args += ["--gpus", str(r.gpus)]
|
|
138
|
+
for m in spec.data_mounts:
|
|
139
|
+
args += ["-v", f"{Path(m.source).resolve()}:/data/{m.name}:ro"]
|
|
140
|
+
for cred in secrets:
|
|
141
|
+
args += ["-e", cred] # value comes from the CLI's environment below
|
|
142
|
+
grace = str(int(max(self._grace, 1)))
|
|
143
|
+
deadline = str(max(int(spec.deadline_s), 1))
|
|
144
|
+
args += [spec.env.ref, "timeout", "-s", "TERM", "-k", grace, deadline,
|
|
145
|
+
"sh", "-c", spec.entrypoint]
|
|
146
|
+
self._cli(*args, env=minimal_env(secrets) | _docker_env())
|
|
147
|
+
return _Container(name=name, ws=ws, deadline_s=spec.deadline_s,
|
|
148
|
+
started=time.monotonic())
|
|
149
|
+
|
|
150
|
+
def _inspect(self, c: _Container) -> dict[str, Any] | None:
|
|
151
|
+
out = self._cli("inspect", c.name, check=False)
|
|
152
|
+
if out.returncode != 0:
|
|
153
|
+
return None
|
|
154
|
+
data = json.loads(out.stdout)
|
|
155
|
+
return data[0] if data else None
|
|
156
|
+
|
|
157
|
+
def _done(self, backend: Any) -> bool:
|
|
158
|
+
c: _Container = backend
|
|
159
|
+
info = self._inspect(c)
|
|
160
|
+
if info is None:
|
|
161
|
+
return True # container gone: lost
|
|
162
|
+
c.info = info
|
|
163
|
+
return info["State"]["Status"] in ("exited", "dead")
|
|
164
|
+
|
|
165
|
+
def _collect(self, backend: Any, spec: JobSpec, secrets: dict[str, str], *,
|
|
166
|
+
killed: bool) -> JobResult:
|
|
167
|
+
c: _Container = backend
|
|
168
|
+
state = c.info.get("State", {}) if c.info else {}
|
|
169
|
+
logs = self._cli("logs", c.name, check=False, timeout=300)
|
|
170
|
+
stdout, n1 = redact(logs.stdout.encode(), secrets)
|
|
171
|
+
stderr, n2 = redact(logs.stderr.encode(), secrets)
|
|
172
|
+
stdout, t1 = cap(stdout)
|
|
173
|
+
stderr, t2 = cap(stderr)
|
|
174
|
+
outputs, n3, truncated = read_outputs(c.ws / "outputs", secrets)
|
|
175
|
+
truncated += [n for n, t in (("stdout", t1), ("stderr", t2)) if t]
|
|
176
|
+
started, ended = state.get("StartedAt"), state.get("FinishedAt")
|
|
177
|
+
duration = _duration(started, ended)
|
|
178
|
+
exit_code = state.get("ExitCode") if state else None
|
|
179
|
+
timed_out = duration is not None and duration >= c.deadline_s and exit_code != 0
|
|
180
|
+
status = classify_status(exit_code, killed=killed or c.killed, timed_out=timed_out,
|
|
181
|
+
crashed=bool(state.get("OOMKilled")))
|
|
182
|
+
if not state:
|
|
183
|
+
status = "lost"
|
|
184
|
+
self._cli("rm", "-f", c.name, check=False)
|
|
185
|
+
# Outputs are captured and redacted above; the workspace may hold whatever the job
|
|
186
|
+
# wrote, credentials included, so it does not outlive the job.
|
|
187
|
+
shutil.rmtree(c.ws.parent, ignore_errors=True)
|
|
188
|
+
usd, metered = cost(self._info.rate_usd_per_hour, duration)
|
|
189
|
+
return JobResult(status=status, exit_code=exit_code, stdout=stdout, stderr=stderr,
|
|
190
|
+
outputs=outputs, metrics=outputs.get(METRICS_FILE),
|
|
191
|
+
started_at=started, ended_at=ended, duration_s=duration,
|
|
192
|
+
cost_usd=usd, metered=metered, env_digest=spec.env.digest,
|
|
193
|
+
code_digest=spec.code_digest, redactions=sorted(set(n1 + n2 + n3)),
|
|
194
|
+
output_cap_bytes=2 * 1024 * 1024, truncated=truncated)
|
|
195
|
+
|
|
196
|
+
def _kill(self, backend: Any) -> None:
|
|
197
|
+
c: _Container = backend
|
|
198
|
+
c.killed = True
|
|
199
|
+
self._cli("kill", c.name, check=False)
|
|
200
|
+
|
|
201
|
+
def reap(self, tags: list[str]) -> list[str]:
|
|
202
|
+
found: set[str] = set()
|
|
203
|
+
for tag in map(check_tag, tags):
|
|
204
|
+
out = self._cli("ps", "-aq", "--filter", f"label={TAG_LABEL}={tag}", check=False)
|
|
205
|
+
ids = out.stdout.split()
|
|
206
|
+
if ids:
|
|
207
|
+
self._cli("rm", "-f", *ids, check=False)
|
|
208
|
+
found.add(tag)
|
|
209
|
+
jobs = self._root / "jobs"
|
|
210
|
+
for marker in jobs.glob(f"*/{TAG_FILE}") if jobs.is_dir() else []:
|
|
211
|
+
tag = marker.read_text(encoding="utf-8").strip()
|
|
212
|
+
if tag in tags:
|
|
213
|
+
shutil.rmtree(marker.parent, ignore_errors=True)
|
|
214
|
+
found.add(tag)
|
|
215
|
+
return sorted(found)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _docker_env() -> dict[str, str]:
|
|
219
|
+
"""Variables the Docker CLI itself needs to reach the daemon."""
|
|
220
|
+
keep = ("DOCKER_HOST", "DOCKER_CONTEXT", "DOCKER_CONFIG", "DOCKER_CERT_PATH",
|
|
221
|
+
"DOCKER_TLS_VERIFY", "USERPROFILE", "HOME", "APPDATA", "LOCALAPPDATA",
|
|
222
|
+
"PROGRAMDATA", "ProgramFiles")
|
|
223
|
+
return {k: os.environ[k] for k in keep if k in os.environ}
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _duration(started: str | None, ended: str | None) -> float | None:
|
|
227
|
+
from datetime import datetime
|
|
228
|
+
|
|
229
|
+
if not started or not ended or ended.startswith("0001"):
|
|
230
|
+
return None
|
|
231
|
+
|
|
232
|
+
def parse(s: str) -> datetime:
|
|
233
|
+
s = s.rstrip("Z")
|
|
234
|
+
if "." in s:
|
|
235
|
+
head, frac = s.split(".", 1)
|
|
236
|
+
s = f"{head}.{frac[:6]}"
|
|
237
|
+
return datetime.fromisoformat(s)
|
|
238
|
+
|
|
239
|
+
return max((parse(ended) - parse(started)).total_seconds(), 0.0)
|