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.
Files changed (171) hide show
  1. app/__init__.py +2 -0
  2. app/__main__.py +31 -0
  3. app/_web/404/index.html +1 -0
  4. app/_web/404.html +1 -0
  5. app/_web/__next.!KGNvbnNvbGUp.__PAGE__.txt +42 -0
  6. app/_web/__next._full.txt +36 -0
  7. app/_web/__next._tree.txt +4 -0
  8. app/_web/_next/static/6az3RZxafySyERvJdZAgV/_buildManifest.js +11 -0
  9. app/_web/_next/static/6az3RZxafySyERvJdZAgV/_clientMiddlewareManifest.js +1 -0
  10. app/_web/_next/static/6az3RZxafySyERvJdZAgV/_ssgManifest.js +1 -0
  11. app/_web/_next/static/chunks/00fylloju-1x2.js +1 -0
  12. app/_web/_next/static/chunks/03hxwfej4sm9g.js +1 -0
  13. app/_web/_next/static/chunks/0cz1d0mv5g_q7.js +1 -0
  14. app/_web/_next/static/chunks/0gus9jro9n3j3.js +1 -0
  15. app/_web/_next/static/chunks/0n12a63ieedt9.js +1 -0
  16. app/_web/_next/static/chunks/0ns7paypbayry.js +31 -0
  17. app/_web/_next/static/chunks/0s1d7y-_wn8wn.js +1 -0
  18. app/_web/_next/static/chunks/12s40azz56b3b.js +1 -0
  19. app/_web/_next/static/chunks/1b_14wnek5geq.js +1 -0
  20. app/_web/_next/static/chunks/1oderptqhsaox.css +1 -0
  21. app/_web/_next/static/chunks/1rcnm-dleh7p6.js +1 -0
  22. app/_web/_next/static/chunks/1u4klctqx_w0w.js +1 -0
  23. app/_web/_next/static/chunks/1vahkxwiu_bqp.js +22 -0
  24. app/_web/_next/static/chunks/26mp4u1wew2xu.js +1 -0
  25. app/_web/_next/static/chunks/2aq880yo-2z88.js +1 -0
  26. app/_web/_next/static/chunks/2f5ovhdilzkal.js +1 -0
  27. app/_web/_next/static/chunks/2qds0qd18u74f.js +1 -0
  28. app/_web/_next/static/chunks/2qgmxvbiean_r.js +1 -0
  29. app/_web/_next/static/chunks/3b5v1mle2gmac.js +1 -0
  30. app/_web/_next/static/chunks/3hw8x3h98ysh4.js +6 -0
  31. app/_web/_next/static/chunks/3op5__grw4752.js +1 -0
  32. app/_web/_next/static/chunks/3scuzzds0vtp7.js +1 -0
  33. app/_web/_next/static/chunks/3v_5wdpvvcxz8.js +1 -0
  34. app/_web/_next/static/chunks/3wevuf_oyh97l.js +1 -0
  35. app/_web/_next/static/chunks/turbopack-3-ry682tkyiqk.js +1 -0
  36. app/_web/_not-found/__next._full.txt +24 -0
  37. app/_web/_not-found/__next._not-found.__PAGE__.txt +30 -0
  38. app/_web/_not-found/__next._tree.txt +4 -0
  39. app/_web/_not-found/index.html +1 -0
  40. app/_web/_not-found/index.txt +24 -0
  41. app/_web/credentials/__next.!KGNvbnNvbGUp.credentials.__PAGE__.txt +46 -0
  42. app/_web/credentials/__next._full.txt +39 -0
  43. app/_web/credentials/__next._tree.txt +5 -0
  44. app/_web/credentials/index.html +1 -0
  45. app/_web/credentials/index.txt +39 -0
  46. app/_web/executors/__next.!KGNvbnNvbGUp.executors.__PAGE__.txt +46 -0
  47. app/_web/executors/__next._full.txt +39 -0
  48. app/_web/executors/__next._tree.txt +5 -0
  49. app/_web/executors/index.html +1 -0
  50. app/_web/executors/index.txt +39 -0
  51. app/_web/index.html +1 -0
  52. app/_web/index.txt +36 -0
  53. app/_web/new/__next.!KGNvbnNvbGUp.new.__PAGE__.txt +46 -0
  54. app/_web/new/__next._full.txt +39 -0
  55. app/_web/new/__next._tree.txt +5 -0
  56. app/_web/new/index.html +1 -0
  57. app/_web/new/index.txt +39 -0
  58. app/_web/profiles/__next.!KGNvbnNvbGUp.profiles.__PAGE__.txt +46 -0
  59. app/_web/profiles/__next._full.txt +39 -0
  60. app/_web/profiles/__next._tree.txt +5 -0
  61. app/_web/profiles/index.html +1 -0
  62. app/_web/profiles/index.txt +39 -0
  63. app/_web/run/__next.!KGNvbnNvbGUp.run.__PAGE__.txt +47 -0
  64. app/_web/run/__next._full.txt +40 -0
  65. app/_web/run/__next._tree.txt +5 -0
  66. app/_web/run/index.html +1 -0
  67. app/_web/run/index.txt +40 -0
  68. app/_web/services/__next.!KGNvbnNvbGUp.services.__PAGE__.txt +46 -0
  69. app/_web/services/__next._full.txt +39 -0
  70. app/_web/services/__next._tree.txt +5 -0
  71. app/_web/services/index.html +1 -0
  72. app/_web/services/index.txt +39 -0
  73. app/_web/setup/__next.!KGNvbnNvbGUp.setup.__PAGE__.txt +46 -0
  74. app/_web/setup/__next._full.txt +39 -0
  75. app/_web/setup/__next._tree.txt +5 -0
  76. app/_web/setup/index.html +1 -0
  77. app/_web/setup/index.txt +39 -0
  78. app/_web/signin/__next._full.txt +30 -0
  79. app/_web/signin/__next._tree.txt +4 -0
  80. app/_web/signin/__next.signin.__PAGE__.txt +36 -0
  81. app/_web/signin/index.html +1 -0
  82. app/_web/signin/index.txt +30 -0
  83. app/accounts.py +137 -0
  84. app/addresses.py +30 -0
  85. app/api/__init__.py +61 -0
  86. app/api/auth_routes.py +54 -0
  87. app/api/common.py +102 -0
  88. app/api/config_routes.py +562 -0
  89. app/api/links.py +22 -0
  90. app/api/run_routes.py +311 -0
  91. app/api/setup_routes.py +267 -0
  92. app/bundled.py +521 -0
  93. app/compose.py +229 -0
  94. app/db.py +186 -0
  95. app/executor_config.py +95 -0
  96. app/keys.py +29 -0
  97. app/launcher.py +389 -0
  98. app/leases.py +40 -0
  99. app/literature_sources.py +131 -0
  100. app/model_endpoints.py +104 -0
  101. app/model_search.py +252 -0
  102. app/outputs.py +77 -0
  103. app/paths.py +93 -0
  104. app/readonly.py +70 -0
  105. app/repo.py +746 -0
  106. app/scan.py +65 -0
  107. app/server.py +24 -0
  108. app/settings.py +125 -0
  109. app/setup.py +117 -0
  110. app/ssh_chain.py +102 -0
  111. app/ssh_hosts.py +244 -0
  112. app/ssh_machines.py +101 -0
  113. app/statuses.py +4 -0
  114. app/supervisor.py +87 -0
  115. app/tunnels.py +222 -0
  116. app/unseal.py +27 -0
  117. app/worker.py +657 -0
  118. executor/__init__.py +47 -0
  119. executor/base.py +161 -0
  120. executor/common.py +139 -0
  121. executor/local_docker.py +239 -0
  122. executor/local_process.py +247 -0
  123. executor/ssh.py +481 -0
  124. executor/types.py +128 -0
  125. executor/watchdog.py +108 -0
  126. ledger/__init__.py +46 -0
  127. ledger/artifacts.py +62 -0
  128. ledger/canonical.py +48 -0
  129. ledger/core.py +337 -0
  130. ledger/errors.py +36 -0
  131. ledger/model.py +107 -0
  132. ledger/payloads.py +228 -0
  133. ledger/rules.py +204 -0
  134. ledger/storage.py +226 -0
  135. llm/__init__.py +45 -0
  136. llm/backends/__init__.py +0 -0
  137. llm/backends/anthropic_backend.py +145 -0
  138. llm/backends/base.py +87 -0
  139. llm/backends/openai_backend.py +211 -0
  140. llm/core.py +416 -0
  141. llm/prices.py +98 -0
  142. llm/tokens.py +18 -0
  143. llm/types.py +106 -0
  144. loop/__init__.py +19 -0
  145. loop/config.py +108 -0
  146. loop/core.py +1160 -0
  147. loop/decide.py +61 -0
  148. loop/harness.py +132 -0
  149. loop/prompts.py +88 -0
  150. loop/view.py +202 -0
  151. puremethod-0.1.0.dist-info/METADATA +79 -0
  152. puremethod-0.1.0.dist-info/RECORD +171 -0
  153. puremethod-0.1.0.dist-info/WHEEL +4 -0
  154. puremethod-0.1.0.dist-info/entry_points.txt +2 -0
  155. puremethod-0.1.0.dist-info/licenses/LICENSE +96 -0
  156. puremethod-0.1.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +173 -0
  157. purescholar/__init__.py +43 -0
  158. purescholar/client.py +380 -0
  159. purescholar/types.py +164 -0
  160. puretrace/__init__.py +49 -0
  161. puretrace/client.py +320 -0
  162. puretrace/types.py +166 -0
  163. writer/__init__.py +8 -0
  164. writer/core.py +430 -0
  165. writer/facts.py +162 -0
  166. writer/latex.py +62 -0
  167. writer/lint.py +85 -0
  168. writer/macros.py +114 -0
  169. writer/render.py +120 -0
  170. writer/tectonic.py +79 -0
  171. writer/types.py +61 -0
executor/ssh.py ADDED
@@ -0,0 +1,481 @@
1
+ """`ssh` backend (05-executor.md §6): a machine the user owns, reached over SSH.
2
+
3
+ Isolation is declared from the host configuration: `container` when jobs run in Docker on
4
+ the host, otherwise `none` (§6). The deadline is enforced on the host by `timeout`, so it
5
+ holds if the engine or the connection goes away (INV-2, §9). Credentials reach the job as
6
+ `export` lines on the remote shell's stdin: never on a command line, never on disk (INV-7).
7
+ Host requirements (§9, KB-015): a POSIX shell, `tar`, `timeout`; Docker for containers.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import base64
13
+ import io
14
+ import shlex
15
+ import stat
16
+ import time
17
+ from dataclasses import dataclass
18
+ from typing import Any
19
+
20
+ import paramiko
21
+
22
+ from .base import CredentialLookup, Executor
23
+ from .common import TAG_FILE, cap, check_tag, cost, process_text, redact, sanitized_tar
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
+ LOST_AFTER = 3 # consecutive failed polls before a job is `lost` (§4)
40
+
41
+ # Kills every process in a session: `pkill -s` where available, else a /proc scan (Linux).
42
+ KILL_SESSION = (
43
+ '_pm_kill_session() { [ -n "$1" ] || return 0; '
44
+ 'if command -v pkill >/dev/null 2>&1; then pkill -KILL -s "$1" 2>/dev/null; '
45
+ 'else for s in /proc/[0-9]*/stat; do set -- "$1" $(cat "$s" 2>/dev/null); '
46
+ '[ "$7" = "$1" ] && kill -KILL "$2" 2>/dev/null; done; fi; return 0; }; '
47
+ )
48
+
49
+
50
+ @dataclass
51
+ class _Remote:
52
+ job_id: str
53
+ dir: str
54
+ deadline_s: float
55
+ killed: bool = False
56
+ failures: int = 0
57
+ lost: bool = False
58
+
59
+
60
+ def _load_key(text: str) -> paramiko.PKey:
61
+ last: Exception | None = None
62
+ for cls in (paramiko.Ed25519Key, paramiko.ECDSAKey, paramiko.RSAKey):
63
+ try:
64
+ return cls.from_private_key(io.StringIO(text))
65
+ except (paramiko.SSHException, ValueError) as exc:
66
+ last = exc
67
+ raise ValueError(f"unsupported or invalid private key ({type(last).__name__})")
68
+
69
+
70
+ class _JumpFailed(OSError):
71
+ """A jump host failed (§5 step 10). The message names the hop, never server text."""
72
+
73
+ def __init__(self, host: str, port: int, user: str, cause: Exception,
74
+ onward: str | None = None) -> None:
75
+ if onward is not None:
76
+ reason = f"jump host {host} cannot reach {onward} ({type(cause).__name__})"
77
+ else:
78
+ reason = "jump host " + _connect_problem(cause, host, port, user, None)
79
+ super().__init__(reason)
80
+ self.reason = reason
81
+
82
+
83
+ def _connect_problem(exc: Exception, host: str, port: int, user: str,
84
+ known_hosts: str | None) -> str:
85
+ """Why the connection failed, in words the user can act on. Built from the exception's
86
+ type and our own settings, never its text beyond a fixed marker: server messages are
87
+ not shown."""
88
+ if isinstance(exc, _JumpFailed):
89
+ return exc.reason
90
+ if isinstance(exc, paramiko.BadHostKeyException):
91
+ return (f"{host}'s host key does not match the trusted one; the server may have been "
92
+ "replaced or impersonated")
93
+ if isinstance(exc, paramiko.AuthenticationException):
94
+ return f"{host} refused the SSH key for user {user!r}"
95
+ if isinstance(exc, FileNotFoundError) and known_hosts:
96
+ return f"the trusted host keys file {known_hosts!r} does not exist"
97
+ if isinstance(exc, paramiko.SSHException) and "not found in known_hosts" in str(exc):
98
+ where = f"in {known_hosts!r}" if known_hosts else "(no trusted host keys file is set)"
99
+ return (f"{host}'s host key is not trusted {where}; connect once with "
100
+ f"`ssh -p {port} {user}@{host}` to verify and trust it")
101
+ if isinstance(exc, ValueError):
102
+ return "the SSH key credential is missing or is not a supported private key"
103
+ if isinstance(exc, OSError):
104
+ return f"cannot reach {host}:{port} ({type(exc).__name__})"
105
+ return type(exc).__name__
106
+
107
+
108
+ class SSHExecutor(Executor):
109
+ def __init__(self, credentials: CredentialLookup, *, executor_id: str = "ssh", host: str,
110
+ user: str, key_credential: str, port: int = 22, remote_root: str = "puremethod",
111
+ use_docker: bool = False, network: Network = "on", concurrency: int = 1,
112
+ rate_usd_per_hour: float | None = None, grace_s: float = 30.0,
113
+ known_hosts: str | None = None, accept_unknown_host: bool = False,
114
+ host_key: str | None = None, jumps: list[dict[str, Any]] | None = None,
115
+ connect_timeout_s: float = 15.0, resources: Resources | None = None) -> None:
116
+ super().__init__(credentials)
117
+ self._info = ExecutorInfo(id=executor_id, kind="ssh",
118
+ isolation="container" if use_docker else "none",
119
+ network=network, concurrency=concurrency,
120
+ resources=resources or Resources(),
121
+ rate_usd_per_hour=rate_usd_per_hour)
122
+ self._host, self._port, self._user = host, port, user
123
+ self._key_credential = key_credential
124
+ self._root = remote_root.strip("/")
125
+ self._docker = use_docker
126
+ self._grace = grace_s
127
+ self._known_hosts = known_hosts
128
+ self._accept_unknown = accept_unknown_host
129
+ self._host_key = host_key # "<type> <base64>": the one key this host may present
130
+ self._timeout = connect_timeout_s
131
+ # §5 step 10 (KB-036): hops before the host, each {host, user, port, key_credential,
132
+ # host_key}; every hop trusts exactly its confirmed key.
133
+ self._jumps = list(jumps or [])
134
+ self._client: paramiko.SSHClient | None = None
135
+ self._hop_clients: list[paramiko.SSHClient] = []
136
+
137
+ def describe(self) -> ExecutorInfo:
138
+ return self._info
139
+
140
+ # ---- connection --------------------------------------------------------------------
141
+
142
+ def _connect(self) -> paramiko.SSHClient:
143
+ if self._client is not None:
144
+ transport = self._client.get_transport()
145
+ if transport is not None and transport.is_active():
146
+ return self._client
147
+ self.close()
148
+ sock: Any = None
149
+ for hop in self._jumps:
150
+ hop_client = self._trusting(str(hop["host"]), int(hop.get("port", 22)),
151
+ str(hop["host_key"]))
152
+ self._hop_clients.append(hop_client)
153
+ try:
154
+ hop_client.connect(str(hop["host"]), port=int(hop.get("port", 22)),
155
+ username=str(hop["user"]), sock=sock,
156
+ pkey=_load_key(self._credentials(str(hop["key_credential"]))),
157
+ timeout=self._timeout, banner_timeout=self._timeout,
158
+ auth_timeout=self._timeout, allow_agent=False,
159
+ look_for_keys=False)
160
+ except Exception as exc:
161
+ self.close()
162
+ raise _JumpFailed(str(hop["host"]), int(hop.get("port", 22)),
163
+ str(hop["user"]), exc) from exc
164
+ nxt = [*self._jumps, {"host": self._host, "port": self._port}][
165
+ len(self._hop_clients)]
166
+ transport = hop_client.get_transport()
167
+ try:
168
+ if transport is None:
169
+ raise paramiko.SSHException("no transport")
170
+ sock = transport.open_channel("direct-tcpip",
171
+ (str(nxt["host"]), int(nxt.get("port", 22))),
172
+ ("127.0.0.1", 0), timeout=self._timeout)
173
+ except Exception as exc:
174
+ self.close()
175
+ raise _JumpFailed(str(hop["host"]), int(hop.get("port", 22)),
176
+ str(hop["user"]), exc, onward=str(nxt["host"])) from exc
177
+ client = self._trusting(self._host, self._port, self._host_key)
178
+ # Unknown host keys are refused unless the configuration explicitly accepts them.
179
+ client.set_missing_host_key_policy(paramiko.AutoAddPolicy() if self._accept_unknown
180
+ else paramiko.RejectPolicy())
181
+ try:
182
+ pkey = _load_key(self._credentials(self._key_credential))
183
+ client.connect(self._host, port=self._port, username=self._user, pkey=pkey,
184
+ sock=sock, timeout=self._timeout, banner_timeout=self._timeout,
185
+ auth_timeout=self._timeout, allow_agent=False, look_for_keys=False)
186
+ except Exception:
187
+ client.close()
188
+ self.close()
189
+ raise
190
+ self._client = client
191
+ return client
192
+
193
+ def _trusting(self, host: str, port: int, host_key: str | None) -> paramiko.SSHClient:
194
+ """A client that trusts this host's confirmed key (and, for the target only, a
195
+ known_hosts file from before KB-035), and refuses any other."""
196
+ client = paramiko.SSHClient()
197
+ if self._known_hosts and host == self._host:
198
+ client.load_host_keys(self._known_hosts)
199
+ if host_key:
200
+ kind, b64 = host_key.split(None, 1)
201
+ name = host if port == 22 else f"[{host}]:{port}"
202
+ client.get_host_keys().add(name, kind, paramiko.PKey.from_type_string(
203
+ kind, base64.b64decode(b64.strip())))
204
+ client.set_missing_host_key_policy(paramiko.RejectPolicy())
205
+ return client
206
+
207
+ def close(self) -> None:
208
+ if self._client is not None:
209
+ self._client.close()
210
+ self._client = None
211
+ for c in reversed(self._hop_clients):
212
+ c.close()
213
+ self._hop_clients = []
214
+
215
+ def _exec(self, command: str, stdin: str | None = None,
216
+ timeout: float = 120) -> tuple[int, str, str]:
217
+ client = self._connect()
218
+ chan_in, out, err = client.exec_command(command, timeout=timeout)
219
+ if stdin is not None:
220
+ chan_in.write(stdin)
221
+ chan_in.channel.shutdown_write()
222
+ rc = out.channel.recv_exit_status()
223
+ return rc, out.read().decode(errors="replace"), err.read().decode(errors="replace")
224
+
225
+ # ---- interface ---------------------------------------------------------------------
226
+
227
+ def preflight(self) -> PreflightReport:
228
+ problems: list[str] = []
229
+ try:
230
+ for tool in ("sh", "tar", "timeout") + (("docker",) if self._docker else ()):
231
+ rc, _, _ = self._exec(f"command -v {tool}")
232
+ if rc != 0:
233
+ problems.append(f"host lacks `{tool}`")
234
+ if self._docker and not problems:
235
+ rc, _, err = self._exec("docker version --format '{{.Server.Version}}'")
236
+ if rc != 0:
237
+ problems.append(f"docker on the host does not answer: {err.strip()[:200]}")
238
+ except (OSError, paramiko.SSHException, ValueError) as exc:
239
+ problem = _connect_problem(exc, self._host, self._port, self._user,
240
+ self._known_hosts)
241
+ return PreflightReport(False, [f"ssh connection failed: {problem}"])
242
+ if problems:
243
+ return PreflightReport(False, problems)
244
+ env = self.build_env(EnvSpec(base_image=DEFAULT_IMAGE if self._docker else None))
245
+ if env.status != "ok":
246
+ return PreflightReport(False, [f"environment: {env.log[-500:]}"])
247
+ handle = self.submit(JobSpec(code=_tar({"ok.sh": b"echo ok\n"}), entrypoint="sh ok.sh",
248
+ env=env, seed=0, deadline_s=60))
249
+ deadline = time.monotonic() + 120
250
+ while self.poll(handle) != "done" and time.monotonic() < deadline:
251
+ time.sleep(0.5)
252
+ if self.poll(handle) != "done" or self.result(handle).status != "ok":
253
+ problems.append("a trivial job did not run")
254
+ return PreflightReport(not problems, problems)
255
+
256
+ def build_env(self, spec: EnvSpec) -> EnvRef:
257
+ digest = spec.digest()
258
+ short = digest.removeprefix("sha256:")[:24]
259
+ t0 = time.monotonic()
260
+ try:
261
+ if self._docker:
262
+ tag = f"pm-env:{short}"
263
+ rc, _, _ = self._exec(f"docker image inspect {tag} >/dev/null 2>&1")
264
+ if rc == 0:
265
+ return EnvRef(digest=digest, status="ok", ref=tag)
266
+ ctx = f"{self._root}/envs/{short}"
267
+ lines = [f"FROM {spec.base_image or DEFAULT_IMAGE}"]
268
+ if spec.requirements_lock.strip():
269
+ lines += ["COPY requirements.lock /tmp/requirements.lock",
270
+ "RUN pip install --no-cache-dir -r /tmp/requirements.lock"]
271
+ self._put_files(ctx, {"Dockerfile": "\n".join(lines) + "\n",
272
+ "requirements.lock": spec.requirements_lock})
273
+ rc, out, err = self._exec(f"docker build -t {tag} {shlex.quote(ctx)}",
274
+ timeout=3600)
275
+ ok = rc == 0
276
+ return EnvRef(digest=digest, status="ok" if ok else "failed",
277
+ ref=tag if ok else "", log=(out + err)[-20000:],
278
+ build_seconds=time.monotonic() - t0, built=ok)
279
+ if not spec.requirements_lock.strip() and spec.python_version is None:
280
+ return EnvRef(digest=digest, status="ok", ref="")
281
+ venv = f"{self._root}/envs/{short}"
282
+ self._put_files(venv, {"requirements.lock": spec.requirements_lock})
283
+ python = (spec.python_version and f"python{spec.python_version}") or "python3"
284
+ cmd = (f"test -f {venv}/.pm-ready || ({python} -m venv {venv}/venv && "
285
+ f"{venv}/venv/bin/pip install -r {venv}/requirements.lock && "
286
+ f"touch {venv}/.pm-ready)")
287
+ rc, out, err = self._exec(cmd, timeout=3600)
288
+ ok = rc == 0
289
+ return EnvRef(digest=digest, status="ok" if ok else "failed",
290
+ ref=f"{venv}/venv/bin" if ok else "", log=(out + err)[-20000:],
291
+ build_seconds=time.monotonic() - t0, built=ok)
292
+ except (OSError, paramiko.SSHException, ValueError) as exc:
293
+ return EnvRef(digest=digest, status="failed", ref="",
294
+ log=f"ssh error: {type(exc).__name__}",
295
+ build_seconds=time.monotonic() - t0)
296
+
297
+ def _put_files(self, remote_dir: str, files: dict[str, str]) -> None:
298
+ self._exec(f"mkdir -p {shlex.quote(remote_dir)}")
299
+ sftp = self._connect().open_sftp()
300
+ try:
301
+ for name, text in files.items():
302
+ with sftp.open(f"{remote_dir}/{name}", "w") as f:
303
+ f.write(text.encode())
304
+ finally:
305
+ sftp.close()
306
+
307
+ # ---- backend hooks -----------------------------------------------------------------
308
+
309
+ def _start(self, job_id: str, spec: JobSpec, secrets: dict[str, str]) -> _Remote:
310
+ d = f"{self._root}/jobs/{job_id}"
311
+ self._exec(f"mkdir -p {d}/ws/outputs")
312
+ if spec.tag is not None: # validated by `submit`: safe in a shell word
313
+ self._exec(f"printf %s {shlex.quote(spec.tag)} > {d}/{TAG_FILE}")
314
+ sftp = self._connect().open_sftp()
315
+ try:
316
+ with sftp.open(f"{d}/code.tar", "wb") as f:
317
+ f.write(sanitized_tar(spec.code))
318
+ finally:
319
+ sftp.close()
320
+ rc, _, err = self._exec(f"tar -xf {d}/code.tar -C {d}/ws && mkdir -p {d}/ws/outputs")
321
+ if rc != 0:
322
+ raise RuntimeError(f"unpacking the code bundle failed: {err.strip()[:200]}")
323
+ grace = str(int(max(self._grace, 1)))
324
+ deadline = str(max(int(spec.deadline_s), 1))
325
+ exports = [f"export {name}={shlex.quote(value)}" for name, value in secrets.items()]
326
+ exports += [f"export PM_SEED={spec.seed}",
327
+ f"export PM_ENTRY={shlex.quote(spec.entrypoint)}"]
328
+ if self._docker:
329
+ flags = " ".join(f"-e {name}" for name in secrets)
330
+ net = "--network none" if self._info.network == "off" else ""
331
+ inner = (f'docker run --name pm-job-{job_id} -v "$PWD":/work -w /work '
332
+ f'-e PM_SEED {flags} {net} {spec.env.ref} '
333
+ f'timeout -s TERM -k {grace} {deadline} sh -c "$PM_ENTRY"')
334
+ else:
335
+ path = f'export PATH="$HOME/{spec.env.ref}:$PATH"; ' if spec.env.ref else ""
336
+ inner = f'{path}timeout -s TERM -k {grace} {deadline} sh -c "$PM_ENTRY"'
337
+ # The job's session is the handle for the whole tree: `timeout` moves the job into a
338
+ # process group of its own, but never out of the session. After the exit code is
339
+ # written, anything the job left behind in its session is killed (INV-3).
340
+ wrapper = (f"{KILL_SESSION}"
341
+ "sid=$(cut -d' ' -f6 /proc/$$/stat 2>/dev/null); [ -n \"$sid\" ] || sid=$$; "
342
+ "echo \"$sid\" > ../sid; "
343
+ f"{inner} > ../stdout 2> ../stderr; echo $? > ../exit_code.tmp; "
344
+ "mv ../exit_code.tmp ../exit_code; _pm_kill_session \"$sid\"")
345
+ script = "\n".join([
346
+ *exports,
347
+ f'cd "$HOME/{d}/ws"',
348
+ "date +%s > ../started",
349
+ "if command -v setsid >/dev/null 2>&1; then",
350
+ f" setsid sh -c {shlex.quote(wrapper)} </dev/null >/dev/null 2>&1 &",
351
+ "else",
352
+ f" nohup sh -c {shlex.quote(wrapper)} </dev/null >/dev/null 2>&1 &",
353
+ "fi",
354
+ "echo $! > ../pid",
355
+ ]) + "\n"
356
+ rc, _, err = self._exec("sh -s", stdin=script)
357
+ if rc != 0:
358
+ raise RuntimeError(f"starting the job failed: {err.strip()[:200]}")
359
+ return _Remote(job_id=job_id, dir=d, deadline_s=spec.deadline_s)
360
+
361
+ def _done(self, backend: Any) -> bool:
362
+ r: _Remote = backend
363
+ try:
364
+ rc, _, _ = self._exec(f"test -f {r.dir}/exit_code", timeout=30)
365
+ r.failures = 0
366
+ return rc == 0
367
+ except (OSError, paramiko.SSHException, EOFError):
368
+ self.close()
369
+ r.failures += 1
370
+ if r.failures >= LOST_AFTER:
371
+ r.lost = True
372
+ return True
373
+ return False
374
+
375
+ def _read(self, sftp: paramiko.SFTPClient, path: str, limit: int | None = None) -> bytes:
376
+ try:
377
+ with sftp.open(path, "rb") as f:
378
+ return bytes(f.read(limit + 1 if limit else -1))
379
+ except OSError:
380
+ return b""
381
+
382
+ def _collect(self, backend: Any, spec: JobSpec, secrets: dict[str, str], *,
383
+ killed: bool) -> JobResult:
384
+ r: _Remote = backend
385
+ if r.lost:
386
+ return JobResult(status="lost", exit_code=None, stdout=b"", stderr=b"", outputs={},
387
+ metrics=None, started_at=None, ended_at=None, duration_s=None,
388
+ cost_usd=None, metered=False, env_digest=spec.env.digest,
389
+ code_digest=spec.code_digest, redactions=[],
390
+ output_cap_bytes=2 * 1024 * 1024)
391
+ limit = 2 * 1024 * 1024
392
+ sftp = self._connect().open_sftp()
393
+ try:
394
+ stdout, n1 = redact(self._read(sftp, f"{r.dir}/stdout", limit), secrets)
395
+ stderr, n2 = redact(self._read(sftp, f"{r.dir}/stderr", limit), secrets)
396
+ code_text = self._read(sftp, f"{r.dir}/exit_code").decode().strip()
397
+ started_text = self._read(sftp, f"{r.dir}/started").decode().strip()
398
+ ended_mtime = sftp.stat(f"{r.dir}/exit_code").st_mtime
399
+ outputs, n3, truncated = self._outputs(sftp, f"{r.dir}/ws/outputs", secrets)
400
+ finally:
401
+ sftp.close()
402
+ stdout, t1 = cap(stdout)
403
+ stderr, t2 = cap(stderr)
404
+ truncated += [n for n, t in (("stdout", t1), ("stderr", t2)) if t]
405
+ exit_code = int(code_text) if code_text.lstrip("-").isdigit() else None
406
+ duration = None
407
+ if started_text.isdigit() and ended_mtime:
408
+ duration = max(float(ended_mtime) - float(started_text), 0.0)
409
+ timed_out = duration is not None and duration >= r.deadline_s and exit_code != 0
410
+ status = classify_status(exit_code, killed=killed or r.killed, timed_out=timed_out,
411
+ crashed=False)
412
+ if self._docker:
413
+ self._exec(f"docker rm -f pm-job-{r.job_id} >/dev/null 2>&1", timeout=60)
414
+ # The remote workspace may hold what the job wrote, credentials included.
415
+ self._exec(f"rm -rf {shlex.quote(r.dir)}", timeout=120)
416
+ usd, metered = cost(self._info.rate_usd_per_hour, duration)
417
+ return JobResult(status=status, exit_code=exit_code, stdout=stdout, stderr=stderr,
418
+ outputs=outputs, metrics=outputs.get(METRICS_FILE), started_at=None,
419
+ ended_at=None, duration_s=duration, cost_usd=usd, metered=metered,
420
+ env_digest=spec.env.digest, code_digest=spec.code_digest,
421
+ redactions=sorted(set(n1 + n2 + n3)), output_cap_bytes=limit,
422
+ truncated=truncated)
423
+
424
+ def _outputs(self, sftp: paramiko.SFTPClient, root: str, secrets: dict[str, str]
425
+ ) -> tuple[dict[str, bytes], list[str], list[str]]:
426
+ files: dict[str, bytes] = {}
427
+ names: list[str] = []
428
+ truncated: list[str] = []
429
+ stack = [""]
430
+ while stack:
431
+ rel = stack.pop()
432
+ try:
433
+ entries = sftp.listdir_attr(f"{root}/{rel}" if rel else root)
434
+ except OSError:
435
+ continue
436
+ for e in entries:
437
+ sub = f"{rel}/{e.filename}" if rel else e.filename
438
+ mode = e.st_mode or 0
439
+ if stat.S_ISDIR(mode):
440
+ stack.append(sub)
441
+ elif stat.S_ISREG(mode):
442
+ data, hit, cut = process_text(self._read(sftp, f"{root}/{sub}"), secrets)
443
+ files[f"outputs/{sub}"] = data
444
+ names += hit
445
+ if cut:
446
+ truncated.append(f"outputs/{sub}")
447
+ return files, names, truncated
448
+
449
+ def _kill(self, backend: Any) -> None:
450
+ r: _Remote = backend
451
+ r.killed = True
452
+ docker = f"docker kill pm-job-{r.job_id} >/dev/null 2>&1; " if self._docker else ""
453
+ script = (KILL_SESSION + docker
454
+ + f"sid=$(cat {r.dir}/sid 2>/dev/null); pid=$(cat {r.dir}/pid 2>/dev/null); "
455
+ + f"test -f {r.dir}/exit_code || echo 137 > {r.dir}/exit_code; "
456
+ + '_pm_kill_session "$sid"; '
457
+ + 'if [ -n "$pid" ]; then kill -KILL -- -"$pid" 2>/dev/null '
458
+ + '|| kill -KILL "$pid" 2>/dev/null; fi; true')
459
+ self._exec(script, timeout=60)
460
+
461
+ def reap(self, tags: list[str]) -> list[str]:
462
+ """Jobs on the host from any engine process, found by their tag file (INV-9): kill
463
+ the session and the process group, remove the container and the job directory."""
464
+ wanted = [check_tag(t) for t in tags]
465
+ if not wanted:
466
+ return []
467
+ docker = ('docker rm -f "pm-job-$(basename "$d")" >/dev/null 2>&1; '
468
+ if self._docker else "")
469
+ listed = " ".join(wanted) # check_tag: no spaces, quotes or shell characters
470
+ script = (KILL_SESSION
471
+ + f"for d in {self._root}/jobs/*; do "
472
+ + f'[ -f "$d/{TAG_FILE}" ] || continue; t=$(cat "$d/{TAG_FILE}"); '
473
+ + f'case " {listed} " in *" $t "*) ;; *) continue;; esac; '
474
+ + docker
475
+ + 'sid=$(cat "$d/sid" 2>/dev/null); pid=$(cat "$d/pid" 2>/dev/null); '
476
+ + '_pm_kill_session "$sid"; '
477
+ + 'if [ -n "$pid" ]; then kill -KILL -- -"$pid" 2>/dev/null '
478
+ + '|| kill -KILL "$pid" 2>/dev/null; fi; '
479
+ + 'rm -rf "$d"; echo "$t"; done; true')
480
+ _, out, _ = self._exec(script, timeout=120)
481
+ return sorted({line.strip() for line in out.splitlines() if line.strip() in wanted})
executor/types.py ADDED
@@ -0,0 +1,128 @@
1
+ """Executor types (05-executor.md §3, §4)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ from dataclasses import asdict, dataclass, field
8
+ from typing import Literal
9
+
10
+ Isolation = Literal["container", "vm", "none"]
11
+ Network = Literal["on", "off"]
12
+ JobState = Literal["queued", "running", "done"]
13
+ Status = Literal["ok", "failed", "crashed", "timeout", "killed", "lost"]
14
+
15
+ DEFAULT_IMAGE = "python:3.11-slim"
16
+ METRICS_FILE = "outputs/pm_metrics.json"
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class Resources:
21
+ cpus: float | None = None
22
+ memory_mb: int | None = None
23
+ gpus: int = 0
24
+ gpu_model: str | None = None
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class ExecutorInfo:
29
+ id: str
30
+ kind: str
31
+ isolation: Isolation
32
+ network: Network
33
+ concurrency: int
34
+ resources: Resources
35
+ rate_usd_per_hour: float | None # None: unmetered
36
+
37
+ @property
38
+ def metered(self) -> bool:
39
+ return self.rate_usd_per_hour is not None
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class EnvSpec:
44
+ base_image: str | None = DEFAULT_IMAGE # container kinds
45
+ python_version: str | None = None # local-process and ssh without Docker
46
+ requirements_lock: str = ""
47
+ system_packages: tuple[str, ...] = ()
48
+
49
+ def digest(self) -> str:
50
+ canonical = json.dumps(asdict(self), sort_keys=True, separators=(",", ":"))
51
+ return "sha256:" + hashlib.sha256(canonical.encode()).hexdigest()
52
+
53
+
54
+ @dataclass(frozen=True)
55
+ class EnvRef:
56
+ digest: str
57
+ status: Literal["ok", "failed"]
58
+ ref: str # image tag, or interpreter path
59
+ log: str = ""
60
+ build_seconds: float = 0.0
61
+ built: bool = False # built by this call (not reused)
62
+
63
+
64
+ @dataclass(frozen=True)
65
+ class DataMount:
66
+ name: str
67
+ source: str # host path the user configured; mounted read-only at /data/<name>
68
+
69
+
70
+ @dataclass(frozen=True)
71
+ class JobSpec:
72
+ code: bytes # a tar archive
73
+ entrypoint: str
74
+ env: EnvRef
75
+ seed: int
76
+ deadline_s: float
77
+ resources: Resources = field(default_factory=Resources)
78
+ credentials: tuple[str, ...] = ()
79
+ data_mounts: tuple[DataMount, ...] = ()
80
+ tag: str | None = None # the caller's id for the job, for `reap` (KB-020)
81
+
82
+ @property
83
+ def code_digest(self) -> str:
84
+ return "sha256:" + hashlib.sha256(self.code).hexdigest()
85
+
86
+
87
+ @dataclass(frozen=True)
88
+ class JobHandle:
89
+ job_id: str
90
+ executor_id: str
91
+
92
+
93
+ @dataclass
94
+ class JobResult:
95
+ status: Status
96
+ exit_code: int | None
97
+ stdout: bytes
98
+ stderr: bytes
99
+ outputs: dict[str, bytes]
100
+ metrics: bytes | None
101
+ started_at: str | None
102
+ ended_at: str | None
103
+ duration_s: float | None
104
+ cost_usd: float | None
105
+ metered: bool
106
+ env_digest: str
107
+ code_digest: str
108
+ redactions: list[str]
109
+ output_cap_bytes: int
110
+ truncated: list[str] = field(default_factory=list)
111
+
112
+
113
+ @dataclass
114
+ class PreflightReport:
115
+ ok: bool
116
+ problems: list[str] = field(default_factory=list)
117
+
118
+
119
+ class ExecutorError(Exception):
120
+ """Base error."""
121
+
122
+
123
+ class UnknownJob(ExecutorError):
124
+ """No job with this handle on this executor."""
125
+
126
+
127
+ class NotDone(ExecutorError):
128
+ """`result` called before `poll` reported `done`."""