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.
- ai_dev_cli_tools-0.5.0a1.dist-info/METADATA +240 -0
- ai_dev_cli_tools-0.5.0a1.dist-info/RECORD +60 -0
- ai_dev_cli_tools-0.5.0a1.dist-info/WHEEL +4 -0
- ai_dev_cli_tools-0.5.0a1.dist-info/entry_points.txt +2 -0
- ai_dev_cli_tools-0.5.0a1.dist-info/licenses/LICENSE +21 -0
- ai_dev_tools/__init__.py +3 -0
- ai_dev_tools/cache/__init__.py +11 -0
- ai_dev_tools/cache/graph.py +136 -0
- ai_dev_tools/cache/repository.py +169 -0
- ai_dev_tools/cache/validation.py +154 -0
- ai_dev_tools/cli.py +387 -0
- ai_dev_tools/completion.py +72 -0
- ai_dev_tools/config.py +223 -0
- ai_dev_tools/context/__init__.py +5 -0
- ai_dev_tools/context/builder.py +506 -0
- ai_dev_tools/context/incremental.py +107 -0
- ai_dev_tools/context/models.py +59 -0
- ai_dev_tools/context/profiles.py +49 -0
- ai_dev_tools/context/selection.py +270 -0
- ai_dev_tools/context/symbols.py +178 -0
- ai_dev_tools/detectors/__init__.py +1 -0
- ai_dev_tools/detectors/environment.py +125 -0
- ai_dev_tools/detectors/project.py +189 -0
- ai_dev_tools/detectors/repository_map.py +129 -0
- ai_dev_tools/detectors/runtime.py +190 -0
- ai_dev_tools/detectors/workspaces.py +228 -0
- ai_dev_tools/git/__init__.py +1 -0
- ai_dev_tools/git/inspect.py +219 -0
- ai_dev_tools/models/__init__.py +1 -0
- ai_dev_tools/models/report.py +95 -0
- ai_dev_tools/models/workspace.py +48 -0
- ai_dev_tools/parsers/__init__.py +1 -0
- ai_dev_tools/parsers/logs.py +372 -0
- ai_dev_tools/parsers/registry.py +60 -0
- ai_dev_tools/reporters/__init__.py +1 -0
- ai_dev_tools/reporters/progressive.py +161 -0
- ai_dev_tools/reporters/writer.py +74 -0
- ai_dev_tools/runners/__init__.py +1 -0
- ai_dev_tools/runners/baseline.py +190 -0
- ai_dev_tools/runners/bootstrap.py +191 -0
- ai_dev_tools/runners/bootstrap_models.py +64 -0
- ai_dev_tools/runners/bootstrap_strategies.py +444 -0
- ai_dev_tools/runners/cache.py +23 -0
- ai_dev_tools/runners/check.py +509 -0
- ai_dev_tools/runners/check_checkpoint.py +50 -0
- ai_dev_tools/runners/check_models.py +51 -0
- ai_dev_tools/runners/check_scheduler.py +94 -0
- ai_dev_tools/runners/check_selection.py +267 -0
- ai_dev_tools/runners/diagnostics.py +96 -0
- ai_dev_tools/runners/feedback.py +193 -0
- ai_dev_tools/runners/finish.py +105 -0
- ai_dev_tools/runners/focused.py +37 -0
- ai_dev_tools/runners/index.py +44 -0
- ai_dev_tools/runtime/__init__.py +3 -0
- ai_dev_tools/runtime/runner.py +380 -0
- ai_dev_tools/runtime/supervisor.py +145 -0
- ai_dev_tools/security/__init__.py +1 -0
- ai_dev_tools/security/secrets.py +58 -0
- ai_dev_tools/utils/__init__.py +1 -0
- 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,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()
|