genesis-cli 0.1.0__tar.gz → 0.3.0__tar.gz

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 (36) hide show
  1. {genesis_cli-0.1.0 → genesis_cli-0.3.0}/PKG-INFO +4 -3
  2. {genesis_cli-0.1.0 → genesis_cli-0.3.0}/pyproject.toml +4 -2
  3. {genesis_cli-0.1.0 → genesis_cli-0.3.0}/src/genesis_cli/adopt.py +8 -1
  4. {genesis_cli-0.1.0 → genesis_cli-0.3.0}/src/genesis_cli/init.py +7 -0
  5. {genesis_cli-0.1.0 → genesis_cli-0.3.0}/src/genesis_cli/main.py +11 -0
  6. genesis_cli-0.3.0/src/genesis_cli/runner/__init__.py +11 -0
  7. genesis_cli-0.3.0/src/genesis_cli/runner/claude_driver.py +319 -0
  8. genesis_cli-0.3.0/src/genesis_cli/runner/client.py +111 -0
  9. genesis_cli-0.3.0/src/genesis_cli/runner/config.py +62 -0
  10. genesis_cli-0.3.0/src/genesis_cli/runner/loop.py +309 -0
  11. genesis_cli-0.3.0/src/genesis_cli/runner/runner_cmd.py +53 -0
  12. genesis_cli-0.3.0/src/genesis_cli/runner/workspace.py +90 -0
  13. {genesis_cli-0.1.0 → genesis_cli-0.3.0}/tests/test_adopt.py +17 -0
  14. genesis_cli-0.3.0/tests/test_runner_completion.py +167 -0
  15. genesis_cli-0.3.0/tests/test_runner_config.py +55 -0
  16. genesis_cli-0.3.0/tests/test_runner_driver.py +135 -0
  17. genesis_cli-0.3.0/tests/test_runner_workspace.py +103 -0
  18. {genesis_cli-0.1.0 → genesis_cli-0.3.0}/.gitignore +0 -0
  19. {genesis_cli-0.1.0 → genesis_cli-0.3.0}/LICENSE +0 -0
  20. {genesis_cli-0.1.0 → genesis_cli-0.3.0}/README.md +0 -0
  21. {genesis_cli-0.1.0 → genesis_cli-0.3.0}/action/README.md +0 -0
  22. {genesis_cli-0.1.0 → genesis_cli-0.3.0}/action/action.yml +0 -0
  23. {genesis_cli-0.1.0 → genesis_cli-0.3.0}/src/genesis_cli/__init__.py +0 -0
  24. {genesis_cli-0.1.0 → genesis_cli-0.3.0}/src/genesis_cli/agents_md.py +0 -0
  25. {genesis_cli-0.1.0 → genesis_cli-0.3.0}/src/genesis_cli/baseline.py +0 -0
  26. {genesis_cli-0.1.0 → genesis_cli-0.3.0}/src/genesis_cli/gates.py +0 -0
  27. {genesis_cli-0.1.0 → genesis_cli-0.3.0}/src/genesis_cli/report.py +0 -0
  28. {genesis_cli-0.1.0 → genesis_cli-0.3.0}/src/genesis_cli/review.py +0 -0
  29. {genesis_cli-0.1.0 → genesis_cli-0.3.0}/src/genesis_cli/sarif.py +0 -0
  30. {genesis_cli-0.1.0 → genesis_cli-0.3.0}/tests/test_baseline.py +0 -0
  31. {genesis_cli-0.1.0 → genesis_cli-0.3.0}/tests/test_gates_cmd.py +0 -0
  32. {genesis_cli-0.1.0 → genesis_cli-0.3.0}/tests/test_init.py +0 -0
  33. {genesis_cli-0.1.0 → genesis_cli-0.3.0}/tests/test_review.py +0 -0
  34. {genesis_cli-0.1.0 → genesis_cli-0.3.0}/tests/test_sarif.py +0 -0
  35. {genesis_cli-0.1.0 → genesis_cli-0.3.0}/tests/test_schemas.py +0 -0
  36. {genesis_cli-0.1.0 → genesis_cli-0.3.0}/tests/test_version.py +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: genesis-cli
3
- Version: 0.1.0
3
+ Version: 0.3.0
4
4
  Summary: Genesis Agent Workbench CLI — make any repo agent-ready and keep AI-generated changes governed.
5
5
  Project-URL: Homepage, https://github.com/Inceptium-ai/genesis-framework
6
6
  Project-URL: Repository, https://github.com/Inceptium-ai/genesis-framework
@@ -20,7 +20,8 @@ Classifier: Programming Language :: Python :: 3.13
20
20
  Classifier: Topic :: Software Development :: Quality Assurance
21
21
  Classifier: Topic :: Software Development :: Testing
22
22
  Requires-Python: >=3.12
23
- Requires-Dist: genesis-gates>=0.1.0
23
+ Requires-Dist: genesis-gates>=0.3.0
24
+ Requires-Dist: httpx>=0.27
24
25
  Requires-Dist: pyyaml>=6.0
25
26
  Description-Content-Type: text/markdown
26
27
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "genesis-cli"
3
- version = "0.1.0"
3
+ version = "0.3.0"
4
4
  description = "Genesis Agent Workbench CLI — make any repo agent-ready and keep AI-generated changes governed."
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
@@ -30,8 +30,10 @@ dependencies = [
30
30
  # Must stay a REAL version requirement: `[tool.uv.sources]` below is a
31
31
  # dev-only workspace override and is not written into published metadata,
32
32
  # so this is what `pip install genesis-cli` resolves against on PyPI.
33
- "genesis-gates>=0.1.0",
33
+ "genesis-gates>=0.3.0",
34
34
  "pyyaml>=6.0",
35
+ # `genesis runner` talks to a Genesis server over HTTP.
36
+ "httpx>=0.27",
35
37
  ]
36
38
 
37
39
  [project.urls]
@@ -17,6 +17,7 @@ import genesis_gates.providers # noqa: F401
17
17
  from genesis_gates import GateContext, GateRunner, get_gate, load_gate_config
18
18
  from genesis_gates.analysis import (
19
19
  analyze,
20
+ declared_env_vars,
20
21
  reconcile,
21
22
  repo_input,
22
23
  score_readiness,
@@ -56,7 +57,13 @@ def run_audit(repo: Path) -> dict:
56
57
  repo = repo.resolve()
57
58
  gate_results = run_gates(repo).get("gate_results", [])
58
59
  completeness = analyze(repo_input(repo))
59
- reconciliation = reconcile(repo, stored_deps={})
60
+ # A brownfield audit has no stored dependency map, so the repo's own
61
+ # `.env.example`-family files ARE its declared config contract. Passing
62
+ # them makes env_hygiene measure "are env vars declared?"; with an empty
63
+ # map, every compose `${VAR}` without a default counted as an undeclared
64
+ # gap even when the repo documents it properly, which pinned the category
65
+ # near zero for any repo with an undefaulted compose reference.
66
+ reconciliation = reconcile(repo, stored_deps=declared_env_vars(repo))
60
67
  facts = structure_facts(repo)
61
68
  readiness = score_readiness(
62
69
  gate_results=gate_results, facts=facts,
@@ -104,6 +104,13 @@ kind: GateConfig
104
104
  # dependency-scan:
105
105
  # provider: trivy
106
106
  # fail_on_severity: critical
107
+ # # Trees this repo does not author and is not accountable for: vendored
108
+ # # dependencies, generated output, packaged sample apps that are scanned
109
+ # # in their own right. Repo-relative, anchored at the root ("vendor" does
110
+ # # not match "packages/x/vendor"). This is SCOPE, not suppression — a
111
+ # # skipped directory is invisible to the gate, so justify each entry in a
112
+ # # comment and keep your own source, infra and CI in scope.
113
+ # skip_dirs: [vendor, third_party/generated]
107
114
  # unit-tests:
108
115
  # provider: command
109
116
  # command: "npm test"
@@ -9,6 +9,12 @@ Commands:
9
9
  genesis init [path] Scaffold a new/bare repo so agents start agent-ready:
10
10
  AGENTS.md, .gitignore, .env.example, audit workflow,
11
11
  gate config. Idempotent — never overwrites.
12
+ genesis runner … Local runner for the Genesis platform: `connect` pairs
13
+ this machine with a server (runner token), `start`
14
+ executes your builds' coding-agent tasks here through
15
+ your own `claude` CLI (your subscription), `status`
16
+ shows the connection. The server keeps orchestration,
17
+ validation, gates, evidence, and billing.
12
18
  genesis --version Print the installed genesis-cli version.
13
19
  """
14
20
 
@@ -220,6 +226,11 @@ def main(argv: list[str] | None = None) -> int:
220
226
  help="directory to scaffold (default: .; created if missing)")
221
227
  init.set_defaults(func=_cmd_init)
222
228
 
229
+ # Local runner (deferred imports: the subcommands import httpx lazily).
230
+ from .runner.runner_cmd import add_runner_parser
231
+
232
+ add_runner_parser(sub)
233
+
223
234
  args = parser.parse_args(argv)
224
235
  return args.func(args)
225
236
 
@@ -0,0 +1,11 @@
1
+ """`genesis runner` — execute your Genesis coding-agent tasks locally.
2
+
3
+ Connect this machine to a Genesis server with a runner token and your builds'
4
+ agent tasks run HERE, through your own local `claude` CLI (your subscription)
5
+ — while the server keeps all orchestration, validation, repair policy,
6
+ commits, gates, evidence, and billing.
7
+
8
+ Public package rule: this code never imports genesis_server or
9
+ genesis_executor; the wire protocol (claim / workspace / events / result)
10
+ is its only contract with the server.
11
+ """
@@ -0,0 +1,319 @@
1
+ """Headless `claude` driver — the public runner-side reimplementation of the
2
+ platform's stream-json adapter (same argv, same parsing, no shared code: this
3
+ package never imports the commercial packages).
4
+
5
+ Runs `claude -p <prompt> --output-format stream-json` as an argv subprocess
6
+ (never a shell) with cwd = the task workspace. The child env is a pass-through
7
+ of the login environment with ANTHROPIC_API_KEY explicitly UNSET so the CLI
8
+ uses the user's subscription auth and never silently bills an API key.
9
+
10
+ Planning completions (0.3.0) use the same CLI in ONE-SHOT print mode —
11
+ `claude -p <prompt> --output-format json --system-prompt <system> --tools ""
12
+ --no-session-persistence` — run in a private empty directory with all tools
13
+ disabled: a pure text-in/text-out LLM call, parsed from the single result
14
+ JSON object (any non-JSON wrapper noise stripped).
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import asyncio
20
+ import json
21
+ import os
22
+ import shutil
23
+ import subprocess
24
+ import tempfile
25
+ import time
26
+ from collections.abc import Callable
27
+ from pathlib import Path
28
+
29
+ # stream-json emits one JSON object per line; a single line (a large tool
30
+ # result or a long assistant turn) routinely exceeds asyncio's default 64 KB
31
+ # StreamReader line cap, which would raise LimitOverrunError mid-task. We read
32
+ # raw chunks and split on newlines ourselves (no per-line cap), and raise the
33
+ # subprocess buffer limit so the transport isn't paused every 64 KB.
34
+ _STREAM_LIMIT = 64 * 1024 * 1024 # 64 MB StreamReader buffer
35
+ _READ_CHUNK = 256 * 1024 # 256 KB per read
36
+
37
+
38
+ async def _iter_json_lines(stream: asyncio.StreamReader):
39
+ """Yield complete decoded lines via chunked reads — an oversized line can
40
+ never raise asyncio.LimitOverrunError."""
41
+ buf = bytearray()
42
+ while True:
43
+ chunk = await stream.read(_READ_CHUNK)
44
+ if not chunk:
45
+ break
46
+ buf.extend(chunk)
47
+ while True:
48
+ nl = buf.find(b"\n")
49
+ if nl == -1:
50
+ break
51
+ line = bytes(buf[:nl])
52
+ del buf[: nl + 1]
53
+ yield line
54
+ if buf:
55
+ yield bytes(buf)
56
+
57
+
58
+ def _assistant_text(evt: dict) -> str:
59
+ msg = evt.get("message") or {}
60
+ parts = msg.get("content") or []
61
+ if isinstance(parts, str):
62
+ return parts
63
+ return "".join(
64
+ p.get("text", "")
65
+ for p in parts
66
+ if isinstance(p, dict) and p.get("type") == "text"
67
+ )
68
+
69
+
70
+ def _flatten_messages(messages: list[dict]) -> str:
71
+ """One-shot print mode takes a single prompt, so a conversation (e.g. the
72
+ server's schema-repair retry: user, assistant, user) is flattened with
73
+ role tags. A single user message passes through untagged."""
74
+ if len(messages) == 1 and messages[0].get("role") == "user":
75
+ return str(messages[0].get("content") or "")
76
+ parts = []
77
+ for m in messages:
78
+ role = str(m.get("role") or "user").upper()
79
+ parts.append(f"[{role}]\n{m.get('content') or ''}")
80
+ return "\n\n".join(parts)
81
+
82
+
83
+ def _final_json_object(stdout: str) -> dict | None:
84
+ """The result object from `--output-format json` stdout, tolerating any
85
+ non-JSON wrapper lines (update notices, MOTD) before or after it."""
86
+ text = stdout.strip()
87
+ try:
88
+ obj = json.loads(text)
89
+ if isinstance(obj, dict):
90
+ return obj
91
+ except json.JSONDecodeError:
92
+ pass
93
+ # Line-wise, last parseable object wins (the result is printed last).
94
+ for line in reversed(text.splitlines()):
95
+ line = line.strip()
96
+ if not (line.startswith("{") and line.endswith("}")):
97
+ continue
98
+ try:
99
+ obj = json.loads(line)
100
+ if isinstance(obj, dict):
101
+ return obj
102
+ except json.JSONDecodeError:
103
+ continue
104
+ # Last resort: scan for embedded objects; the last decodable one wins.
105
+ dec = json.JSONDecoder()
106
+ best: dict | None = None
107
+ i = text.find("{")
108
+ while i != -1:
109
+ try:
110
+ obj, end = dec.raw_decode(text, i)
111
+ except json.JSONDecodeError:
112
+ i = text.find("{", i + 1)
113
+ continue
114
+ if isinstance(obj, dict):
115
+ best = obj
116
+ i = text.find("{", end)
117
+ return best
118
+
119
+
120
+ def check_cli() -> tuple[bool, str]:
121
+ """Preflight: is a working `claude` CLI on PATH?"""
122
+ if not shutil.which("claude"):
123
+ return False, (
124
+ "The `claude` CLI is not on PATH. Install Claude Code "
125
+ "(https://claude.com/claude-code) and sign in with your "
126
+ "subscription, then run `genesis runner start` again."
127
+ )
128
+ try:
129
+ proc = subprocess.run(["claude", "--version"], capture_output=True,
130
+ text=True, timeout=30)
131
+ except (OSError, subprocess.TimeoutExpired) as exc:
132
+ return False, f"`claude --version` failed to run: {exc}"
133
+ if proc.returncode != 0:
134
+ return False, (f"`claude --version` exited {proc.returncode}: "
135
+ f"{(proc.stderr or proc.stdout)[:200]}")
136
+ return True, (proc.stdout or "").strip()
137
+
138
+
139
+ class ClaudeDriver:
140
+ """One task = one run(). kill() aborts the live subprocess (server-side
141
+ cancel or a second Ctrl-C)."""
142
+
143
+ def __init__(self, model: str | None = None):
144
+ self.model = model
145
+ self._proc: asyncio.subprocess.Process | None = None
146
+ self.killed = False
147
+
148
+ def kill(self) -> None:
149
+ self.killed = True
150
+ if self._proc is not None and self._proc.returncode is None:
151
+ self._proc.kill()
152
+
153
+ def _child_env(self) -> dict:
154
+ env = dict(os.environ)
155
+ # Force subscription auth: never let an API key take over accidentally.
156
+ env.pop("ANTHROPIC_API_KEY", None)
157
+ return env
158
+
159
+ async def run(self, prompt: str, *, workdir: Path,
160
+ resume_session: str | None = None,
161
+ on_text: Callable[[str], None] | None = None) -> dict:
162
+ """Run one task; returns {status, session_id, usage, error} parsed
163
+ from the CLI's final result message."""
164
+ started = time.monotonic()
165
+ argv = [
166
+ "claude", "-p", prompt,
167
+ "--output-format", "stream-json",
168
+ "--verbose",
169
+ "--add-dir", str(workdir),
170
+ "--permission-mode", "acceptEdits",
171
+ ]
172
+ if self.model:
173
+ argv += ["--model", self.model]
174
+ if resume_session:
175
+ argv += ["--resume", resume_session]
176
+
177
+ try:
178
+ proc = await asyncio.create_subprocess_exec(
179
+ *argv,
180
+ cwd=str(workdir),
181
+ env=self._child_env(),
182
+ stdout=asyncio.subprocess.PIPE,
183
+ stderr=asyncio.subprocess.PIPE,
184
+ limit=_STREAM_LIMIT,
185
+ )
186
+ except FileNotFoundError:
187
+ return {"status": "failed", "session_id": None,
188
+ "usage": {}, "error": "claude CLI not found"}
189
+ self._proc = proc
190
+
191
+ usage: dict = {"input_tokens": 0, "output_tokens": 0, "cost_usd": None,
192
+ "estimated": False, "duration_s": None}
193
+ session_id: str | None = None
194
+ status = "failed"
195
+ error: str | None = None
196
+
197
+ assert proc.stdout is not None
198
+ try:
199
+ async for raw in _iter_json_lines(proc.stdout):
200
+ line = raw.decode("utf-8", "replace").strip()
201
+ if not line:
202
+ continue
203
+ try:
204
+ evt = json.loads(line)
205
+ except json.JSONDecodeError:
206
+ continue
207
+ etype = evt.get("type")
208
+ if evt.get("session_id"):
209
+ session_id = evt["session_id"]
210
+ if etype == "assistant":
211
+ text = _assistant_text(evt)
212
+ if text and on_text is not None:
213
+ on_text(text)
214
+ elif etype == "result":
215
+ if evt.get("total_cost_usd") is not None:
216
+ usage["cost_usd"] = float(evt["total_cost_usd"])
217
+ u = evt.get("usage") or {}
218
+ usage["input_tokens"] = int(u.get("input_tokens", 0))
219
+ usage["output_tokens"] = int(u.get("output_tokens", 0))
220
+ status = "completed" if evt.get("subtype") == "success" else "failed"
221
+ if evt.get("is_error"):
222
+ error = str(evt.get("result", "cli error"))[:500]
223
+ await proc.wait()
224
+ except asyncio.CancelledError:
225
+ proc.kill()
226
+ raise
227
+ finally:
228
+ self._proc = None
229
+
230
+ if self.killed:
231
+ status = "interrupted"
232
+ error = error or "task aborted on the runner"
233
+ elif proc.returncode not in (0, None) and status != "completed":
234
+ status = "failed"
235
+ if error is None:
236
+ err = (await proc.stderr.read()).decode("utf-8", "replace") \
237
+ if proc.stderr else ""
238
+ error = err[:500] or f"claude exited {proc.returncode}"
239
+
240
+ usage["duration_s"] = time.monotonic() - started
241
+ return {"status": status, "session_id": session_id,
242
+ "usage": usage, "error": error}
243
+
244
+ async def run_completion(self, *, system: str | None, messages: list[dict],
245
+ max_tokens: int = 4096,
246
+ timeout_s: float = 150.0) -> dict:
247
+ """One planning completion (T-047): run `claude` in one-shot print
248
+ mode and return {status, text, usage, error}. Verified invocation:
249
+ `claude -p <prompt> --output-format json --system-prompt <system>
250
+ --tools "" --no-session-persistence` — a single result JSON object on
251
+ stdout whose `result` field is the model's text. Tools are disabled
252
+ and cwd is a throwaway empty directory: a pure LLM call, nothing on
253
+ this machine is read or written. The CLI exposes no max-tokens flag;
254
+ the server bounds output through the prompt contract instead.
255
+ """
256
+ del max_tokens # accepted for wire-payload symmetry; see docstring
257
+ started = time.monotonic()
258
+ prompt = _flatten_messages(messages)
259
+ argv = [
260
+ "claude", "-p", prompt,
261
+ "--output-format", "json",
262
+ "--tools", "",
263
+ "--no-session-persistence",
264
+ ]
265
+ if system:
266
+ argv += ["--system-prompt", system]
267
+ if self.model:
268
+ argv += ["--model", self.model]
269
+
270
+ usage: dict = {"input_tokens": 0, "output_tokens": 0, "duration_s": None}
271
+ with tempfile.TemporaryDirectory(prefix="genesis-completion-") as empty:
272
+ try:
273
+ proc = await asyncio.create_subprocess_exec(
274
+ *argv,
275
+ cwd=empty,
276
+ env=self._child_env(),
277
+ stdout=asyncio.subprocess.PIPE,
278
+ stderr=asyncio.subprocess.PIPE,
279
+ limit=_STREAM_LIMIT,
280
+ )
281
+ except FileNotFoundError:
282
+ return {"status": "failed", "text": None, "usage": usage,
283
+ "error": "claude CLI not found"}
284
+ self._proc = proc
285
+ try:
286
+ out_b, err_b = await asyncio.wait_for(proc.communicate(),
287
+ timeout=timeout_s)
288
+ except TimeoutError:
289
+ proc.kill()
290
+ await proc.wait()
291
+ return {"status": "failed", "text": None, "usage": usage,
292
+ "error": f"completion timed out after {timeout_s:.0f}s"}
293
+ except asyncio.CancelledError:
294
+ proc.kill()
295
+ raise
296
+ finally:
297
+ self._proc = None
298
+
299
+ usage["duration_s"] = time.monotonic() - started
300
+ if self.killed:
301
+ return {"status": "interrupted", "text": None, "usage": usage,
302
+ "error": "completion aborted on the runner"}
303
+
304
+ evt = _final_json_object(out_b.decode("utf-8", "replace"))
305
+ if evt is None:
306
+ err = err_b.decode("utf-8", "replace")[:500]
307
+ return {"status": "failed", "text": None, "usage": usage,
308
+ "error": err or f"claude exited {proc.returncode} "
309
+ "with no result JSON"}
310
+ u = evt.get("usage") or {}
311
+ usage["input_tokens"] = int(u.get("input_tokens") or 0)
312
+ usage["output_tokens"] = int(u.get("output_tokens") or 0)
313
+ text = evt.get("result")
314
+ if evt.get("is_error") or evt.get("subtype") != "success" \
315
+ or not isinstance(text, str):
316
+ return {"status": "failed", "text": None, "usage": usage,
317
+ "error": str(evt.get("result") or "cli error")[:500]}
318
+ return {"status": "completed", "text": text, "usage": usage,
319
+ "error": None}
@@ -0,0 +1,111 @@
1
+ """HTTP client for the Genesis runner gateway.
2
+
3
+ One rule above all: the token is a credential — it goes into the
4
+ Authorization header and NOWHERE else (never a log line, never an error
5
+ message, never a URL).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from pathlib import Path
11
+
12
+ import httpx
13
+
14
+ # The server long-polls a claim for ~25s; read must comfortably outlast that.
15
+ _CLAIM_TIMEOUT = httpx.Timeout(10.0, read=60.0)
16
+ _IO_TIMEOUT = httpx.Timeout(10.0, read=300.0)
17
+
18
+ # What this client can serve, advertised on every claim (0.3.0+). 'completion'
19
+ # = one-shot planning LLM calls through the local `claude` CLI, no workspace.
20
+ # Additive wire change: an 0.2.0 server has no body on /claim and ignores it;
21
+ # an 0.3.0 server hands completion items only to claims that advertise it.
22
+ CAPABILITIES = ("completion",)
23
+
24
+
25
+ class RunnerAuthError(RuntimeError):
26
+ """The server rejected the runner token (bad, rotated, or revoked)."""
27
+
28
+
29
+ class RunnerClient:
30
+ def __init__(self, server: str, token: str):
31
+ self._client = httpx.AsyncClient(
32
+ base_url=server.rstrip("/"),
33
+ headers={"Authorization": f"Bearer {token}"},
34
+ timeout=_IO_TIMEOUT,
35
+ )
36
+
37
+ async def aclose(self) -> None:
38
+ await self._client.aclose()
39
+
40
+ @staticmethod
41
+ def _check_auth(resp: httpx.Response) -> None:
42
+ if resp.status_code == 401:
43
+ raise RunnerAuthError(
44
+ "The server rejected this runner's token — it may have been "
45
+ "rotated or revoked. Run `genesis runner connect` again."
46
+ )
47
+
48
+ async def probe(self) -> bool:
49
+ """Side-effect-free auth check: a nonexistent work item reads 404 with
50
+ a valid token, 401 with a bad one (and never claims real work)."""
51
+ resp = await self._client.get("/api/runner/work/probe/workspace")
52
+ if resp.status_code == 401:
53
+ return False
54
+ return resp.status_code == 404
55
+
56
+ async def claim(self) -> dict | None:
57
+ """One long-poll. None = no work this window (poll again)."""
58
+ resp = await self._client.post(
59
+ "/api/runner/claim", json={"capabilities": list(CAPABILITIES)},
60
+ timeout=_CLAIM_TIMEOUT,
61
+ )
62
+ self._check_auth(resp)
63
+ if resp.status_code == 204:
64
+ return None
65
+ resp.raise_for_status()
66
+ data = resp.json()
67
+ return data if isinstance(data, dict) and data.get("item_id") else None
68
+
69
+ async def download_workspace(self, item_id: str, dest: Path) -> Path:
70
+ """Stream the task workspace archive to dest (tar.gz)."""
71
+ async with self._client.stream(
72
+ "GET", f"/api/runner/work/{item_id}/workspace", timeout=_IO_TIMEOUT
73
+ ) as resp:
74
+ self._check_auth(resp)
75
+ resp.raise_for_status()
76
+ with dest.open("wb") as f:
77
+ async for chunk in resp.aiter_bytes():
78
+ f.write(chunk)
79
+ return dest
80
+
81
+ async def post_events(self, item_id: str, events: list[dict]) -> bool:
82
+ """Forward an event batch; the reply carries the cancel signal."""
83
+ resp = await self._client.post(
84
+ f"/api/runner/work/{item_id}/events", json={"events": events}
85
+ )
86
+ self._check_auth(resp)
87
+ resp.raise_for_status()
88
+ try:
89
+ return bool(resp.json().get("cancelled"))
90
+ except ValueError:
91
+ return False
92
+
93
+ async def post_result(self, item_id: str, result: dict,
94
+ tar_path: Path | None = None) -> None:
95
+ """Upload the task outcome (+ the changed-files archive when any)."""
96
+ import json
97
+
98
+ data = {"result": json.dumps(result)}
99
+ if tar_path is not None:
100
+ with tar_path.open("rb") as f:
101
+ resp = await self._client.post(
102
+ f"/api/runner/work/{item_id}/result", data=data,
103
+ files={"files": ("files.tar.gz", f, "application/gzip")},
104
+ timeout=_IO_TIMEOUT,
105
+ )
106
+ else:
107
+ resp = await self._client.post(
108
+ f"/api/runner/work/{item_id}/result", data=data, timeout=_IO_TIMEOUT
109
+ )
110
+ self._check_auth(resp)
111
+ resp.raise_for_status()
@@ -0,0 +1,62 @@
1
+ """Runner configuration: ~/.genesis/runner.toml, mode 0600.
2
+
3
+ The file holds the server URL, the runner token (a credential — hence the
4
+ restrictive mode and the minimal writer that never round-trips through a
5
+ general serializer), and this machine's runner name.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import tomllib
12
+ from pathlib import Path
13
+
14
+ _FIELDS = ("server", "token", "name")
15
+
16
+
17
+ def config_dir() -> Path:
18
+ return Path(os.environ.get("GENESIS_RUNNER_HOME", str(Path.home() / ".genesis")))
19
+
20
+
21
+ def config_path() -> Path:
22
+ return config_dir() / "runner.toml"
23
+
24
+
25
+ def work_root() -> Path:
26
+ """Where claimed task workspaces are materialized (and cleaned up)."""
27
+ root = config_dir() / "runner-work"
28
+ root.mkdir(parents=True, exist_ok=True)
29
+ return root
30
+
31
+
32
+ def load_config() -> dict:
33
+ path = config_path()
34
+ if not path.exists():
35
+ return {}
36
+ try:
37
+ data = tomllib.loads(path.read_text())
38
+ except (OSError, tomllib.TOMLDecodeError):
39
+ return {}
40
+ return {k: v for k, v in data.items() if k in _FIELDS and isinstance(v, str)}
41
+
42
+
43
+ def _toml_str(value: str) -> str:
44
+ # Minimal TOML basic-string escaping: backslash and double quote.
45
+ return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"'
46
+
47
+
48
+ def save_config(server: str, token: str, name: str) -> Path:
49
+ path = config_path()
50
+ path.parent.mkdir(parents=True, exist_ok=True)
51
+ body = (
52
+ "# genesis runner credentials — created by `genesis runner connect`\n"
53
+ f"server = {_toml_str(server)}\n"
54
+ f"token = {_toml_str(token)}\n"
55
+ f"name = {_toml_str(name)}\n"
56
+ )
57
+ # Create 0600 BEFORE writing the secret (never a world-readable window).
58
+ fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
59
+ with os.fdopen(fd, "w") as f:
60
+ f.write(body)
61
+ os.chmod(path, 0o600)
62
+ return path