ai-dev-cli-tools 0.5.0a1__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 (60) hide show
  1. ai_dev_cli_tools-0.5.0a1.dist-info/METADATA +240 -0
  2. ai_dev_cli_tools-0.5.0a1.dist-info/RECORD +60 -0
  3. ai_dev_cli_tools-0.5.0a1.dist-info/WHEEL +4 -0
  4. ai_dev_cli_tools-0.5.0a1.dist-info/entry_points.txt +2 -0
  5. ai_dev_cli_tools-0.5.0a1.dist-info/licenses/LICENSE +21 -0
  6. ai_dev_tools/__init__.py +3 -0
  7. ai_dev_tools/cache/__init__.py +11 -0
  8. ai_dev_tools/cache/graph.py +136 -0
  9. ai_dev_tools/cache/repository.py +169 -0
  10. ai_dev_tools/cache/validation.py +154 -0
  11. ai_dev_tools/cli.py +387 -0
  12. ai_dev_tools/completion.py +72 -0
  13. ai_dev_tools/config.py +223 -0
  14. ai_dev_tools/context/__init__.py +5 -0
  15. ai_dev_tools/context/builder.py +506 -0
  16. ai_dev_tools/context/incremental.py +107 -0
  17. ai_dev_tools/context/models.py +59 -0
  18. ai_dev_tools/context/profiles.py +49 -0
  19. ai_dev_tools/context/selection.py +270 -0
  20. ai_dev_tools/context/symbols.py +178 -0
  21. ai_dev_tools/detectors/__init__.py +1 -0
  22. ai_dev_tools/detectors/environment.py +125 -0
  23. ai_dev_tools/detectors/project.py +189 -0
  24. ai_dev_tools/detectors/repository_map.py +129 -0
  25. ai_dev_tools/detectors/runtime.py +190 -0
  26. ai_dev_tools/detectors/workspaces.py +228 -0
  27. ai_dev_tools/git/__init__.py +1 -0
  28. ai_dev_tools/git/inspect.py +219 -0
  29. ai_dev_tools/models/__init__.py +1 -0
  30. ai_dev_tools/models/report.py +95 -0
  31. ai_dev_tools/models/workspace.py +48 -0
  32. ai_dev_tools/parsers/__init__.py +1 -0
  33. ai_dev_tools/parsers/logs.py +372 -0
  34. ai_dev_tools/parsers/registry.py +60 -0
  35. ai_dev_tools/reporters/__init__.py +1 -0
  36. ai_dev_tools/reporters/progressive.py +161 -0
  37. ai_dev_tools/reporters/writer.py +74 -0
  38. ai_dev_tools/runners/__init__.py +1 -0
  39. ai_dev_tools/runners/baseline.py +190 -0
  40. ai_dev_tools/runners/bootstrap.py +191 -0
  41. ai_dev_tools/runners/bootstrap_models.py +64 -0
  42. ai_dev_tools/runners/bootstrap_strategies.py +444 -0
  43. ai_dev_tools/runners/cache.py +23 -0
  44. ai_dev_tools/runners/check.py +509 -0
  45. ai_dev_tools/runners/check_checkpoint.py +50 -0
  46. ai_dev_tools/runners/check_models.py +51 -0
  47. ai_dev_tools/runners/check_scheduler.py +94 -0
  48. ai_dev_tools/runners/check_selection.py +267 -0
  49. ai_dev_tools/runners/diagnostics.py +96 -0
  50. ai_dev_tools/runners/feedback.py +193 -0
  51. ai_dev_tools/runners/finish.py +105 -0
  52. ai_dev_tools/runners/focused.py +37 -0
  53. ai_dev_tools/runners/index.py +44 -0
  54. ai_dev_tools/runtime/__init__.py +3 -0
  55. ai_dev_tools/runtime/runner.py +380 -0
  56. ai_dev_tools/runtime/supervisor.py +145 -0
  57. ai_dev_tools/security/__init__.py +1 -0
  58. ai_dev_tools/security/secrets.py +58 -0
  59. ai_dev_tools/utils/__init__.py +1 -0
  60. ai_dev_tools/utils/subprocess.py +74 -0
@@ -0,0 +1,105 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Sequence
4
+ from pathlib import Path
5
+
6
+ from ai_dev_tools.config import load_settings
7
+ from ai_dev_tools.git.inspect import inspect_git
8
+ from ai_dev_tools.models.report import Report
9
+ from ai_dev_tools.reporters.writer import write_json, write_markdown
10
+ from ai_dev_tools.runners.check import run_check
11
+ from ai_dev_tools.security.secrets import scan_paths_for_secrets
12
+
13
+ UNSAFE_REPO_STATES = {"CONFLICT", "DETACHED_HEAD", "DIVERGED"}
14
+
15
+
16
+ def run_finish(project_root: Path) -> Report:
17
+ settings = load_settings(project_root)
18
+ git_report = inspect_git(settings.project_root, detailed=True)
19
+ changed = [str(path) for path in git_report.summary.get("changed_files", [])]
20
+ check_report = run_check(settings.project_root, mode="changed")
21
+ findings = scan_paths_for_secrets(
22
+ settings.project_root, [settings.project_root / path for path in changed]
23
+ )
24
+ blocking_reasons = _blocking_reasons(git_report, check_report, findings, changed)
25
+ ready = not blocking_reasons
26
+ report = Report(
27
+ command="finish",
28
+ project_root=settings.project_root,
29
+ status="success" if ready else "failed",
30
+ )
31
+ report.summary = {
32
+ "ready_to_commit": ready,
33
+ "blocking_reasons": blocking_reasons,
34
+ "blocking_reason_codes": [_blocking_reason_code(reason) for reason in blocking_reasons],
35
+ "changed": _classify_changed(changed),
36
+ "validation": {
37
+ "checks": check_report.status,
38
+ "secrets": "none" if not findings else f"{len(findings)} finding(s)",
39
+ },
40
+ "secret_findings": [finding.masked_dict() for finding in findings],
41
+ "reports": {
42
+ "git": [artifact.path for artifact in git_report.artifacts],
43
+ "check": [artifact.path for artifact in check_report.artifacts],
44
+ },
45
+ }
46
+ report.finish()
47
+ write_markdown(report, settings.reports_directory / "finish-latest.md")
48
+ write_json(report, settings.reports_directory / "finish-latest.json")
49
+ return report
50
+
51
+
52
+ def _blocking_reasons(
53
+ git_report: Report,
54
+ check_report: Report,
55
+ findings: Sequence[object],
56
+ changed: list[str],
57
+ ) -> list[str]:
58
+ reasons: list[str] = []
59
+ states = set(str(item) for item in git_report.summary.get("states", []))
60
+ conflicts = git_report.summary.get("conflicted_files", [])
61
+ if not changed:
62
+ reasons.append("no_changes")
63
+ if conflicts:
64
+ reasons.append("repository has merge conflicts")
65
+ for state in sorted(states & UNSAFE_REPO_STATES):
66
+ if state != "CONFLICT":
67
+ reasons.append(f"repository state is {state}")
68
+ if check_report.status == "failed":
69
+ checks_failed = check_report.summary.get("checks_failed")
70
+ if isinstance(checks_failed, int) and checks_failed:
71
+ reasons.append(f"{checks_failed} check(s) failed")
72
+ else:
73
+ reasons.append("required checks failed")
74
+ if findings:
75
+ reasons.append(f"{len(findings)} potential secret(s) detected")
76
+ return reasons
77
+
78
+
79
+ def _blocking_reason_code(reason: str) -> str:
80
+ if reason == "no_changes":
81
+ return "NO_CHANGES"
82
+ if "merge conflicts" in reason:
83
+ return "MERGE_CONFLICTS"
84
+ if reason.startswith("repository state is "):
85
+ return f"UNSAFE_REPOSITORY_{reason.rsplit(' ', 1)[-1]}"
86
+ if "check" in reason and "failed" in reason:
87
+ return "CHECKS_FAILED"
88
+ if "secret" in reason:
89
+ return "POTENTIAL_SECRETS"
90
+ return "FINISH_BLOCKED"
91
+
92
+
93
+ def _classify_changed(paths: list[str]) -> dict[str, int]:
94
+ result = {"production_files": 0, "test_files": 0, "documentation_files": 0, "other_files": 0}
95
+ for path in paths:
96
+ lower = path.lower()
97
+ if lower.endswith((".md", ".rst", ".txt")) or lower.startswith("docs/"):
98
+ result["documentation_files"] += 1
99
+ elif "test" in lower:
100
+ result["test_files"] += 1
101
+ elif lower.endswith((".py", ".js", ".ts", ".java", ".rs", ".php")):
102
+ result["production_files"] += 1
103
+ else:
104
+ result["other_files"] += 1
105
+ return result
@@ -0,0 +1,37 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+
5
+ from ai_dev_tools.runners.check_models import CheckTask
6
+
7
+
8
+ def focused_rerun(task: CheckTask, parsed: dict[str, object]) -> list[str] | None:
9
+ parser = str(parsed.get("parser") or parsed.get("tool") or "").lower()
10
+ path = _first_project_file(parsed)
11
+ if not path:
12
+ return None
13
+ if "pytest" in parser or path.endswith(".py"):
14
+ return [sys.executable, "-m", "pytest", path]
15
+ if any(name in parser for name in ("jest", "vitest")) or path.endswith(
16
+ (".js", ".jsx", ".ts", ".tsx")
17
+ ):
18
+ return ["npm", "test", "--", path]
19
+ if "phpunit" in parser or path.endswith(".php"):
20
+ return ["vendor/bin/phpunit", path]
21
+ if "maven" in parser and task.workspace:
22
+ return ["mvn", "-pl", task.workspace, "test"]
23
+ if "gradle" in parser and task.workspace:
24
+ return ["gradle", f":{task.workspace.replace('/', ':')}:test"]
25
+ return None
26
+
27
+
28
+ def _first_project_file(parsed: dict[str, object]) -> str | None:
29
+ frames = parsed.get("project_frames")
30
+ if not isinstance(frames, list):
31
+ return None
32
+ for frame in frames:
33
+ if isinstance(frame, dict) and isinstance(frame.get("file"), str):
34
+ return str(frame["file"])
35
+ if isinstance(frame, str) and frame:
36
+ return frame.split(":", 1)[0]
37
+ return None
@@ -0,0 +1,44 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from ai_dev_tools.cache.repository import (
6
+ INDEX_RELATIVE_PATH,
7
+ read_repository_index,
8
+ update_repository_index,
9
+ )
10
+ from ai_dev_tools.models.report import Artifact, Report
11
+
12
+
13
+ def run_index(project_root: Path, action: str) -> Report:
14
+ root = project_root.resolve()
15
+ report = Report(command=f"index {action}", project_root=root)
16
+ if action == "status":
17
+ index = read_repository_index(root)
18
+ if not index:
19
+ report.status = "partial"
20
+ report.summary = {
21
+ "indexed": False,
22
+ "message": "Repository index does not exist. Run `ai-dev index update`.",
23
+ "reason_code": "INDEX_MISSING",
24
+ }
25
+ return report
26
+ else:
27
+ index = update_repository_index(root, rebuild=action == "rebuild")
28
+
29
+ index_path = root / INDEX_RELATIVE_PATH
30
+ report.summary = {
31
+ "indexed": True,
32
+ "schema_version": index.get("schema_version"),
33
+ "generated_at": index.get("generated_at"),
34
+ **_summary(index.get("summary")),
35
+ }
36
+ report.artifacts.append(Artifact(str(index_path), "repository-index", "Repository file index"))
37
+ return report
38
+
39
+
40
+ def _summary(value: object) -> dict[str, object]:
41
+ if not isinstance(value, dict):
42
+ return {}
43
+ allowed = {"files", "hashed", "reused", "removed"}
44
+ return {str(key): item for key, item in value.items() if key in allowed}
@@ -0,0 +1,3 @@
1
+ from ai_dev_tools.runtime.runner import RunOptions, run_application, stop_application
2
+
3
+ __all__ = ["RunOptions", "run_application", "stop_application"]
@@ -0,0 +1,380 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import secrets
6
+ import socket
7
+ import subprocess
8
+ import sys
9
+ import time
10
+ from contextlib import suppress
11
+ from dataclasses import asdict, dataclass
12
+ from pathlib import Path
13
+ from urllib import error as urllib_error
14
+ from urllib import request as urllib_request
15
+
16
+ from ai_dev_tools.config import Settings, load_settings
17
+ from ai_dev_tools.models.report import Artifact, Issue, Report
18
+ from ai_dev_tools.reporters.writer import write_json, write_markdown
19
+ from ai_dev_tools.security.secrets import mask_text
20
+ from ai_dev_tools.utils.subprocess import run_command, split_command
21
+
22
+
23
+ @dataclass(frozen=True, slots=True)
24
+ class RunOptions:
25
+ explain: bool = False
26
+ dry_run: bool = False
27
+ foreground: bool = False
28
+ timeout_seconds: int = 300
29
+ readiness_http: str | None = None
30
+ readiness_tcp: str | None = None
31
+ startup_timeout_seconds: int = 10
32
+ startup_log_lines: int = 50
33
+
34
+
35
+ @dataclass(frozen=True, slots=True)
36
+ class RunPlan:
37
+ command: list[str]
38
+ source: str
39
+ cwd: str = ""
40
+
41
+ def to_dict(self) -> dict[str, object]:
42
+ return asdict(self)
43
+
44
+
45
+ def run_application(project_root: Path, options: RunOptions) -> Report:
46
+ settings = load_settings(project_root)
47
+ report = Report(command="run", project_root=settings.project_root)
48
+ plan = resolve_run_plan(settings)
49
+ paths = _runtime_paths(settings.project_root)
50
+ current = _read_json(paths["metadata"])
51
+ if current.get("status") in {"running", "stopping"} and _heartbeat_is_fresh(current):
52
+ report.status = "blocked"
53
+ report.issues.append(
54
+ Issue("error", "A managed project process is already running.", code="ALREADY_RUNNING")
55
+ )
56
+ elif plan is None:
57
+ report.status = "blocked"
58
+ report.issues.append(
59
+ Issue(
60
+ "error",
61
+ "No safe run command detected. Configure [commands].run.",
62
+ code="NO_RUN_COMMAND",
63
+ )
64
+ )
65
+ elif options.explain or options.dry_run:
66
+ report.summary = {
67
+ "plan": plan.to_dict(),
68
+ "explain": options.explain,
69
+ "dry_run": options.dry_run,
70
+ "modifications": "NONE",
71
+ "readiness": _readiness_config(options),
72
+ }
73
+ elif options.foreground:
74
+ result = run_command(
75
+ plan.command,
76
+ settings.project_root / plan.cwd,
77
+ timeout_seconds=options.timeout_seconds,
78
+ )
79
+ report.status = "success" if result.exit_code == 0 else "failed"
80
+ report.exit_code = result.exit_code
81
+ paths["log"].parent.mkdir(parents=True, exist_ok=True)
82
+ paths["log"].write_text(mask_text(result.combined_output) + "\n", encoding="utf-8")
83
+ report.artifacts.append(Artifact(str(paths["log"]), "log", "Application output"))
84
+ report.summary = {
85
+ "plan": plan.to_dict(),
86
+ "mode": "foreground",
87
+ "exit_code": result.exit_code,
88
+ "timed_out": result.timed_out,
89
+ }
90
+ else:
91
+ token = secrets.token_urlsafe(24)
92
+ _remove_stale_request(paths["request"])
93
+ supervisor_command = [
94
+ sys.executable,
95
+ str(Path(__file__).with_name("supervisor.py")),
96
+ "--metadata",
97
+ str(paths["metadata"]),
98
+ "--request",
99
+ str(paths["request"]),
100
+ "--token",
101
+ token,
102
+ "--cwd",
103
+ str(settings.project_root / plan.cwd),
104
+ "--log",
105
+ str(paths["log"]),
106
+ "--",
107
+ *plan.command,
108
+ ]
109
+ _spawn_supervisor(supervisor_command, settings.project_root)
110
+ state = _wait_for_state(paths["metadata"], {"running", "exited"}, 10.0)
111
+ readiness: dict[str, object] = {"status": "not_configured"}
112
+ if state.get("status") != "running":
113
+ report.status = "failed"
114
+ report.issues.append(
115
+ Issue("error", "Managed process failed to start.", code="START_FAILED")
116
+ )
117
+ else:
118
+ readiness = _wait_for_readiness(options)
119
+ if readiness["status"] == "ready":
120
+ report.status = "success"
121
+ else:
122
+ report.status = "failed"
123
+ report.issues.append(
124
+ Issue(
125
+ "error",
126
+ "Managed process did not pass its readiness check in time.",
127
+ code="READINESS_TIMEOUT",
128
+ )
129
+ )
130
+ _request_managed_stop(paths, token, 5)
131
+ report.summary = {
132
+ "plan": plan.to_dict(),
133
+ "mode": "background",
134
+ "status": state.get("status", "unknown"),
135
+ "supervisor_pid": state.get("supervisor_pid"),
136
+ "child_pid": state.get("child_pid"),
137
+ "readiness": readiness,
138
+ "startup_log": _tail_log(paths["log"], options.startup_log_lines),
139
+ "stale_metadata_recovered": bool(current) and not _heartbeat_is_fresh(current),
140
+ }
141
+ report.artifacts.extend(
142
+ [
143
+ Artifact(str(paths["log"]), "log", "Application output"),
144
+ Artifact(str(paths["metadata"]), "runtime-state", "Managed process state"),
145
+ ]
146
+ )
147
+ if not report.summary:
148
+ report.summary = {
149
+ "plan": plan.to_dict() if plan else None,
150
+ "explain": options.explain,
151
+ "dry_run": options.dry_run,
152
+ }
153
+ report.finish()
154
+ write_markdown(report, settings.reports_directory / "run-latest.md")
155
+ write_json(report, settings.reports_directory / "run-latest.json")
156
+ return report
157
+
158
+
159
+ def stop_application(
160
+ project_root: Path, *, explain: bool = False, timeout_seconds: int = 10
161
+ ) -> Report:
162
+ settings = load_settings(project_root)
163
+ report = Report(command="stop", project_root=settings.project_root)
164
+ paths = _runtime_paths(settings.project_root)
165
+ state = _read_json(paths["metadata"])
166
+ public_state = _public_state(state)
167
+ if not state:
168
+ report.status = "partial"
169
+ report.summary = {"status": "not_running", "state": {}}
170
+ elif state.get("status") not in {"running", "stopping"}:
171
+ report.status = "partial"
172
+ report.summary = {"status": "not_running", "state": public_state}
173
+ elif not isinstance(state.get("token"), str):
174
+ report.status = "blocked"
175
+ report.issues.append(
176
+ Issue(
177
+ "error", "Runtime state has no valid control token.", code="INVALID_RUNTIME_STATE"
178
+ )
179
+ )
180
+ report.summary = {"status": "unknown", "state": public_state}
181
+ elif explain:
182
+ report.summary = {
183
+ "status": "would_request_stop",
184
+ "state": public_state,
185
+ "modifications": "NONE",
186
+ }
187
+ else:
188
+ paths["request"].parent.mkdir(parents=True, exist_ok=True)
189
+ paths["request"].write_text(str(state["token"]), encoding="utf-8")
190
+ stopped = _wait_for_state(paths["metadata"], {"stopped", "exited"}, timeout_seconds)
191
+ if stopped.get("status") not in {"stopped", "exited"}:
192
+ report.status = "failed"
193
+ report.issues.append(
194
+ Issue(
195
+ "error",
196
+ "Supervisor did not acknowledge the stop request; no arbitrary PID was killed.",
197
+ code="STOP_TIMEOUT",
198
+ )
199
+ )
200
+ else:
201
+ report.status = "success"
202
+ report.summary = {
203
+ "status": stopped.get("status", "unknown"),
204
+ "state": _public_state(stopped),
205
+ }
206
+ if paths["metadata"].exists():
207
+ report.artifacts.append(
208
+ Artifact(str(paths["metadata"]), "runtime-state", "Managed process state")
209
+ )
210
+ report.finish()
211
+ write_markdown(report, settings.reports_directory / "stop-latest.md")
212
+ write_json(report, settings.reports_directory / "stop-latest.json")
213
+ return report
214
+
215
+
216
+ def resolve_run_plan(settings: Settings) -> RunPlan | None:
217
+ configured = settings.commands.get("run")
218
+ if configured:
219
+ command = split_command(configured)
220
+ return RunPlan(command, "configured") if command else None
221
+ package_path = settings.project_root / "package.json"
222
+ if package_path.exists():
223
+ try:
224
+ package = json.loads(package_path.read_text(encoding="utf-8"))
225
+ except json.JSONDecodeError:
226
+ return None
227
+ scripts = package.get("scripts", {}) if isinstance(package, dict) else {}
228
+ if isinstance(scripts, dict):
229
+ for name in ("dev", "start", "serve"):
230
+ if isinstance(scripts.get(name), str):
231
+ manager = _node_manager(settings.project_root)
232
+ return RunPlan([manager, "run", name], f"package.json#scripts.{name}")
233
+ return None
234
+
235
+
236
+ def _node_manager(root: Path) -> str:
237
+ if (root / "pnpm-lock.yaml").exists():
238
+ return "pnpm"
239
+ if (root / "yarn.lock").exists():
240
+ return "yarn"
241
+ return "npm"
242
+
243
+
244
+ def _spawn_supervisor(command: list[str], cwd: Path) -> None:
245
+ flags = 0
246
+ start_new_session = os.name != "nt"
247
+ if os.name == "nt":
248
+ flags = (
249
+ getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
250
+ | getattr(subprocess, "DETACHED_PROCESS", 0)
251
+ | getattr(subprocess, "CREATE_NO_WINDOW", 0)
252
+ )
253
+ subprocess.Popen(
254
+ command,
255
+ cwd=cwd,
256
+ stdin=subprocess.DEVNULL,
257
+ stdout=subprocess.DEVNULL,
258
+ stderr=subprocess.DEVNULL,
259
+ close_fds=True,
260
+ shell=False,
261
+ start_new_session=start_new_session,
262
+ creationflags=flags,
263
+ )
264
+
265
+
266
+ def _readiness_config(options: RunOptions) -> dict[str, object]:
267
+ return {
268
+ "http": options.readiness_http,
269
+ "tcp": options.readiness_tcp,
270
+ "timeout_seconds": options.startup_timeout_seconds,
271
+ }
272
+
273
+
274
+ def _wait_for_readiness(options: RunOptions) -> dict[str, object]:
275
+ if options.readiness_http is None and options.readiness_tcp is None:
276
+ return {"status": "ready", "check": "process_started", "attempts": 0}
277
+ deadline = time.monotonic() + max(options.startup_timeout_seconds, 0)
278
+ attempts = 0
279
+ last_error = ""
280
+ while True:
281
+ attempts += 1
282
+ try:
283
+ if options.readiness_http is not None:
284
+ with urllib_request.urlopen(options.readiness_http, timeout=0.5) as response:
285
+ if response.status >= 500:
286
+ raise OSError(f"HTTP {response.status}")
287
+ if options.readiness_tcp is not None:
288
+ host, port = _parse_tcp_endpoint(options.readiness_tcp)
289
+ with socket.create_connection((host, port), timeout=0.5):
290
+ pass
291
+ return {"status": "ready", "check": _readiness_config(options), "attempts": attempts}
292
+ except (OSError, ValueError, urllib_error.URLError) as exc:
293
+ last_error = mask_text(str(exc))
294
+ if time.monotonic() >= deadline:
295
+ break
296
+ time.sleep(0.1)
297
+ return {
298
+ "status": "timeout",
299
+ "check": _readiness_config(options),
300
+ "attempts": attempts,
301
+ "last_error": last_error,
302
+ }
303
+
304
+
305
+ def _parse_tcp_endpoint(value: str) -> tuple[str, int]:
306
+ host, separator, port_text = value.rpartition(":")
307
+ if not separator or not host:
308
+ raise ValueError("TCP readiness must use host:port")
309
+ port = int(port_text)
310
+ if not 1 <= port <= 65535:
311
+ raise ValueError("TCP readiness port must be between 1 and 65535")
312
+ return host, port
313
+
314
+
315
+ def _tail_log(path: Path, max_lines: int) -> list[str]:
316
+ if max_lines <= 0:
317
+ return []
318
+ try:
319
+ return path.read_text(encoding="utf-8", errors="replace").splitlines()[-max_lines:]
320
+ except OSError:
321
+ return []
322
+
323
+
324
+ def _request_managed_stop(paths: dict[str, Path], token: str, timeout: int) -> None:
325
+ paths["request"].parent.mkdir(parents=True, exist_ok=True)
326
+ paths["request"].write_text(token, encoding="utf-8")
327
+ _wait_for_state(paths["metadata"], {"stopped", "exited"}, timeout)
328
+
329
+
330
+ def _runtime_paths(root: Path) -> dict[str, Path]:
331
+ directory = root / ".ai" / "runtime"
332
+ return {
333
+ "metadata": directory / "process.json",
334
+ "request": directory / "stop.request",
335
+ "log": root / ".ai" / "logs" / "run-latest.log",
336
+ }
337
+
338
+
339
+ def _wait_for_state(path: Path, statuses: set[str], timeout: float) -> dict[str, object]:
340
+ deadline = time.monotonic() + max(timeout, 0)
341
+ latest: dict[str, object] = {}
342
+ while time.monotonic() <= deadline:
343
+ latest = _read_json(path)
344
+ if latest.get("status") in statuses:
345
+ return latest
346
+ pending = _read_json(path.with_suffix(path.suffix + ".tmp"))
347
+ if pending.get("status") in statuses:
348
+ return pending
349
+ time.sleep(0.05)
350
+ return latest
351
+
352
+
353
+ def _read_json(path: Path) -> dict[str, object]:
354
+ try:
355
+ value = json.loads(path.read_text(encoding="utf-8"))
356
+ return value if isinstance(value, dict) else {}
357
+ except (OSError, json.JSONDecodeError):
358
+ return {}
359
+
360
+
361
+ def _heartbeat_is_fresh(state: dict[str, object]) -> bool:
362
+ heartbeat = state.get("heartbeat_at")
363
+ if not isinstance(heartbeat, str):
364
+ return False
365
+ try:
366
+ from datetime import datetime
367
+
368
+ age = datetime.now().astimezone() - datetime.fromisoformat(heartbeat).astimezone()
369
+ return age.total_seconds() < 5
370
+ except ValueError:
371
+ return False
372
+
373
+
374
+ def _public_state(state: dict[str, object]) -> dict[str, object]:
375
+ return {key: value for key, value in state.items() if key != "token"}
376
+
377
+
378
+ def _remove_stale_request(path: Path) -> None:
379
+ with suppress(FileNotFoundError):
380
+ path.unlink()