codemate-team 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.
- codemate/__init__.py +3 -0
- codemate/agents.py +141 -0
- codemate/artifacts.py +125 -0
- codemate/cli.py +228 -0
- codemate/commands.py +36 -0
- codemate/config.py +433 -0
- codemate/git_tools.py +102 -0
- codemate/policy.py +34 -0
- codemate/workflow.py +402 -0
- codemate/yaml_lite.py +168 -0
- codemate_team-0.1.0.dist-info/METADATA +132 -0
- codemate_team-0.1.0.dist-info/RECORD +16 -0
- codemate_team-0.1.0.dist-info/WHEEL +5 -0
- codemate_team-0.1.0.dist-info/entry_points.txt +2 -0
- codemate_team-0.1.0.dist-info/licenses/LICENSE +21 -0
- codemate_team-0.1.0.dist-info/top_level.txt +1 -0
codemate/__init__.py
ADDED
codemate/agents.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import shutil
|
|
5
|
+
import subprocess
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class AgentRunInput:
|
|
13
|
+
run_id: str
|
|
14
|
+
step_id: str
|
|
15
|
+
cwd: Path
|
|
16
|
+
prompt: str
|
|
17
|
+
mode: str
|
|
18
|
+
expected_output: str
|
|
19
|
+
output_path: Path
|
|
20
|
+
raw_log_path: Path
|
|
21
|
+
schema_path: Path | None
|
|
22
|
+
config: dict[str, Any]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class AgentRunResult:
|
|
27
|
+
ok: bool
|
|
28
|
+
output_path: Path
|
|
29
|
+
raw_log_path: Path
|
|
30
|
+
exit_code: int
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class AgentAdapter:
|
|
34
|
+
def run(self, input: AgentRunInput) -> AgentRunResult:
|
|
35
|
+
raise NotImplementedError
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class CodexCliAdapter(AgentAdapter):
|
|
39
|
+
def run(self, input: AgentRunInput) -> AgentRunResult:
|
|
40
|
+
command = str(input.config.get("command", "codex"))
|
|
41
|
+
_require_executable(command)
|
|
42
|
+
args = [
|
|
43
|
+
command,
|
|
44
|
+
"exec",
|
|
45
|
+
"--cd",
|
|
46
|
+
str(input.cwd),
|
|
47
|
+
"--sandbox",
|
|
48
|
+
str(input.config.get("sandbox", "workspace-write")),
|
|
49
|
+
"--ask-for-approval",
|
|
50
|
+
str(input.config.get("approval", "never")),
|
|
51
|
+
"--output-last-message",
|
|
52
|
+
str(input.output_path),
|
|
53
|
+
]
|
|
54
|
+
if input.schema_path and input.schema_path.exists():
|
|
55
|
+
args.extend(["--output-schema", str(input.schema_path)])
|
|
56
|
+
args.append(input.prompt)
|
|
57
|
+
return _run_process(args, input)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class ClaudeCodeAdapter(AgentAdapter):
|
|
61
|
+
def run(self, input: AgentRunInput) -> AgentRunResult:
|
|
62
|
+
command = str(input.config.get("command", "claude"))
|
|
63
|
+
_require_executable(command)
|
|
64
|
+
permission_mode = _claude_permission_mode(input.mode, input.config)
|
|
65
|
+
output_format = str(input.config.get("output_format", "text"))
|
|
66
|
+
args = [command, "-p", input.prompt, "--permission-mode", permission_mode]
|
|
67
|
+
if output_format != "text":
|
|
68
|
+
args.extend(["--output-format", output_format])
|
|
69
|
+
result = _run_process(args, input)
|
|
70
|
+
# Claude Code streams its final answer to stdout (captured in the raw log)
|
|
71
|
+
# rather than to a dedicated output file, so derive the output ourselves.
|
|
72
|
+
if not input.output_path.read_text(errors="replace").strip():
|
|
73
|
+
raw = input.raw_log_path.read_text(errors="replace")
|
|
74
|
+
input.output_path.write_text(_extract_claude_message(raw, output_format))
|
|
75
|
+
return result
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def adapter_for(config: dict[str, Any]) -> AgentAdapter:
|
|
79
|
+
provider = str(config.get("provider", "")).lower()
|
|
80
|
+
if provider == "codex-cli":
|
|
81
|
+
return CodexCliAdapter()
|
|
82
|
+
if provider == "claude-code":
|
|
83
|
+
return ClaudeCodeAdapter()
|
|
84
|
+
raise ValueError(f"Unsupported agent provider: {provider}")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _extract_claude_message(raw: str, output_format: str) -> str:
|
|
88
|
+
"""Pull the assistant's final message out of captured Claude Code output.
|
|
89
|
+
|
|
90
|
+
With `--output-format json` the CLI emits a result envelope; extract its
|
|
91
|
+
`result` field. Anything else (including malformed JSON) falls back to the
|
|
92
|
+
raw captured text so no output is ever silently dropped.
|
|
93
|
+
"""
|
|
94
|
+
if output_format == "json":
|
|
95
|
+
try:
|
|
96
|
+
data = json.loads(raw.strip() or "{}")
|
|
97
|
+
except json.JSONDecodeError:
|
|
98
|
+
return raw
|
|
99
|
+
if isinstance(data, dict) and isinstance(data.get("result"), str):
|
|
100
|
+
return data["result"]
|
|
101
|
+
return raw
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _claude_permission_mode(mode: str, config: dict[str, Any]) -> str:
|
|
105
|
+
if mode in {"read_only", "review_only"}:
|
|
106
|
+
return "plan"
|
|
107
|
+
if mode == "write":
|
|
108
|
+
return str(config.get("write_permission_mode", "acceptEdits"))
|
|
109
|
+
return str(config.get("default_mode", "plan"))
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _run_process(args: list[str], input: AgentRunInput) -> AgentRunResult:
|
|
113
|
+
timeout = int(input.config.get("timeout_seconds", 900))
|
|
114
|
+
with input.raw_log_path.open("w") as raw_log:
|
|
115
|
+
try:
|
|
116
|
+
result = subprocess.run(
|
|
117
|
+
args,
|
|
118
|
+
cwd=input.cwd,
|
|
119
|
+
text=True,
|
|
120
|
+
stdout=raw_log,
|
|
121
|
+
stderr=subprocess.STDOUT,
|
|
122
|
+
timeout=timeout,
|
|
123
|
+
check=False,
|
|
124
|
+
)
|
|
125
|
+
exit_code = result.returncode
|
|
126
|
+
except subprocess.TimeoutExpired:
|
|
127
|
+
raw_log.write(f"\n[timeout after {timeout} seconds]\n")
|
|
128
|
+
exit_code = 124
|
|
129
|
+
if not input.output_path.exists():
|
|
130
|
+
input.output_path.write_text("")
|
|
131
|
+
return AgentRunResult(
|
|
132
|
+
ok=exit_code == 0,
|
|
133
|
+
output_path=input.output_path,
|
|
134
|
+
raw_log_path=input.raw_log_path,
|
|
135
|
+
exit_code=exit_code,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _require_executable(command: str) -> None:
|
|
140
|
+
if shutil.which(command) is None:
|
|
141
|
+
raise FileNotFoundError(f"Required agent command not found: {command}")
|
codemate/artifacts.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def new_run_id() -> str:
|
|
12
|
+
return datetime.now().strftime("%Y-%m-%dT%H-%M-%S-%f")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def latest_run_dir(root: Path) -> Path:
|
|
16
|
+
runs = root / ".team" / "runs"
|
|
17
|
+
candidates = sorted([path for path in runs.glob("*") if path.is_dir()])
|
|
18
|
+
if not candidates:
|
|
19
|
+
raise FileNotFoundError("No runs found")
|
|
20
|
+
return candidates[-1]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class RunState:
|
|
25
|
+
run_id: str
|
|
26
|
+
task: str
|
|
27
|
+
flow: str
|
|
28
|
+
status: str
|
|
29
|
+
branch: str
|
|
30
|
+
base_branch: str
|
|
31
|
+
base_head: str
|
|
32
|
+
current_step: str | None = None
|
|
33
|
+
steps: list[dict[str, Any]] = field(default_factory=list)
|
|
34
|
+
changed_files: list[str] = field(default_factory=list)
|
|
35
|
+
reason: str | None = None
|
|
36
|
+
|
|
37
|
+
def to_dict(self) -> dict[str, Any]:
|
|
38
|
+
return {
|
|
39
|
+
"run_id": self.run_id,
|
|
40
|
+
"task": self.task,
|
|
41
|
+
"flow": self.flow,
|
|
42
|
+
"status": self.status,
|
|
43
|
+
"branch": self.branch,
|
|
44
|
+
"base_branch": self.base_branch,
|
|
45
|
+
"base_head": self.base_head,
|
|
46
|
+
"current_step": self.current_step,
|
|
47
|
+
"steps": self.steps,
|
|
48
|
+
"changed_files": self.changed_files,
|
|
49
|
+
"reason": self.reason,
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
@classmethod
|
|
53
|
+
def from_path(cls, path: Path) -> "RunState":
|
|
54
|
+
data = json.loads(path.read_text())
|
|
55
|
+
return cls(**data)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class RunArtifacts:
|
|
59
|
+
def __init__(self, root: Path, run_id: str) -> None:
|
|
60
|
+
self.root = root
|
|
61
|
+
self.run_id = run_id
|
|
62
|
+
self.dir = root / ".team" / "runs" / run_id
|
|
63
|
+
self.dir.mkdir(parents=True, exist_ok=True)
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def state_path(self) -> Path:
|
|
67
|
+
return self.dir / "state.json"
|
|
68
|
+
|
|
69
|
+
def write_state(self, state: RunState) -> None:
|
|
70
|
+
self.state_path.write_text(json.dumps(state.to_dict(), indent=2) + "\n")
|
|
71
|
+
|
|
72
|
+
def write_text(self, name: str, content: str) -> Path:
|
|
73
|
+
path = self.dir / name
|
|
74
|
+
path.write_text(content)
|
|
75
|
+
return path
|
|
76
|
+
|
|
77
|
+
def read_text(self, name: str, default: str = "") -> str:
|
|
78
|
+
path = self.dir / name
|
|
79
|
+
return path.read_text() if path.exists() else default
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class RunLock:
|
|
83
|
+
def __init__(self, root: Path, run_id: str) -> None:
|
|
84
|
+
self.path = root / ".team" / "lock.json"
|
|
85
|
+
self.run_id = run_id
|
|
86
|
+
|
|
87
|
+
def __enter__(self) -> "RunLock":
|
|
88
|
+
if self.path.exists():
|
|
89
|
+
if self._is_stale():
|
|
90
|
+
self.path.unlink()
|
|
91
|
+
else:
|
|
92
|
+
raise RuntimeError(f"Another codemate run appears active: {self.path}")
|
|
93
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
94
|
+
self.path.write_text(
|
|
95
|
+
json.dumps(
|
|
96
|
+
{
|
|
97
|
+
"run_id": self.run_id,
|
|
98
|
+
"pid": os.getpid(),
|
|
99
|
+
"created_at": datetime.now().isoformat(timespec="seconds"),
|
|
100
|
+
},
|
|
101
|
+
indent=2,
|
|
102
|
+
)
|
|
103
|
+
+ "\n"
|
|
104
|
+
)
|
|
105
|
+
return self
|
|
106
|
+
|
|
107
|
+
def _is_stale(self) -> bool:
|
|
108
|
+
try:
|
|
109
|
+
data = json.loads(self.path.read_text())
|
|
110
|
+
except (OSError, json.JSONDecodeError):
|
|
111
|
+
return False
|
|
112
|
+
pid = data.get("pid")
|
|
113
|
+
if not isinstance(pid, int) or pid <= 0:
|
|
114
|
+
return False
|
|
115
|
+
try:
|
|
116
|
+
os.kill(pid, 0)
|
|
117
|
+
except ProcessLookupError:
|
|
118
|
+
return True
|
|
119
|
+
except PermissionError:
|
|
120
|
+
return False
|
|
121
|
+
return False
|
|
122
|
+
|
|
123
|
+
def __exit__(self, _exc_type: object, _exc: object, _tb: object) -> None:
|
|
124
|
+
if self.path.exists():
|
|
125
|
+
self.path.unlink()
|
codemate/cli.py
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import shutil
|
|
5
|
+
import shlex
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from .artifacts import RunState, latest_run_dir
|
|
10
|
+
from .config import init_project, load_config
|
|
11
|
+
from .git_tools import add_and_commit, changed_files, diff, ensure_repo, restore_paths
|
|
12
|
+
from .workflow import run_task
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def main(argv: list[str] | None = None) -> int:
|
|
16
|
+
parser = argparse.ArgumentParser(prog="codemate")
|
|
17
|
+
parser.add_argument("--root", default=".", help="Project root")
|
|
18
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
19
|
+
|
|
20
|
+
init_parser = subparsers.add_parser("init", help="Create team.yml and .team files")
|
|
21
|
+
init_parser.add_argument("--force", action="store_true")
|
|
22
|
+
|
|
23
|
+
doctor_parser = subparsers.add_parser("doctor", help="Check local harness dependencies")
|
|
24
|
+
doctor_parser.add_argument("--flow")
|
|
25
|
+
|
|
26
|
+
run_parser = subparsers.add_parser("run", help="Run a task through the configured flow")
|
|
27
|
+
run_parser.add_argument("task", nargs="?")
|
|
28
|
+
run_parser.add_argument("--file")
|
|
29
|
+
run_parser.add_argument("--flow")
|
|
30
|
+
|
|
31
|
+
status_parser = subparsers.add_parser("status", help="Show run status")
|
|
32
|
+
status_parser.add_argument("--run-id")
|
|
33
|
+
|
|
34
|
+
logs_parser = subparsers.add_parser("logs", help="Show run logs")
|
|
35
|
+
logs_parser.add_argument("--run-id")
|
|
36
|
+
logs_parser.add_argument("--step")
|
|
37
|
+
|
|
38
|
+
subparsers.add_parser("diff", help="Show current git diff")
|
|
39
|
+
|
|
40
|
+
accept_parser = subparsers.add_parser("accept", help="Accept current run changes")
|
|
41
|
+
accept_parser.add_argument("--commit", action="store_true")
|
|
42
|
+
accept_parser.add_argument("--message", default=None)
|
|
43
|
+
|
|
44
|
+
reset_parser = subparsers.add_parser("reset", help="Reset files changed by latest run")
|
|
45
|
+
reset_parser.add_argument("--run-id")
|
|
46
|
+
|
|
47
|
+
args = parser.parse_args(argv)
|
|
48
|
+
root = Path(args.root).resolve()
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
if args.command == "init":
|
|
52
|
+
return _init(root, args.force)
|
|
53
|
+
if args.command == "doctor":
|
|
54
|
+
return _doctor(root, args.flow)
|
|
55
|
+
if args.command == "run":
|
|
56
|
+
task = _read_task(args)
|
|
57
|
+
config = load_config(root)
|
|
58
|
+
state = run_task(config, task, args.flow)
|
|
59
|
+
print(_format_state(state))
|
|
60
|
+
print(f"Run artifacts: .team/runs/{state.run_id}")
|
|
61
|
+
return 0
|
|
62
|
+
if args.command == "status":
|
|
63
|
+
state = _load_state(root, args.run_id)
|
|
64
|
+
print(_format_state(state))
|
|
65
|
+
return 0
|
|
66
|
+
if args.command == "logs":
|
|
67
|
+
return _logs(root, args.run_id, args.step)
|
|
68
|
+
if args.command == "diff":
|
|
69
|
+
ensure_repo(root)
|
|
70
|
+
print(diff(root), end="")
|
|
71
|
+
return 0
|
|
72
|
+
if args.command == "accept":
|
|
73
|
+
return _accept(root, args.commit, args.message)
|
|
74
|
+
if args.command == "reset":
|
|
75
|
+
return _reset(root, args.run_id)
|
|
76
|
+
except Exception as exc:
|
|
77
|
+
print(f"codemate: error: {exc}", file=sys.stderr)
|
|
78
|
+
return 1
|
|
79
|
+
return 1
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _init(root: Path, force: bool) -> int:
|
|
83
|
+
created = init_project(root, force=force)
|
|
84
|
+
if not created:
|
|
85
|
+
print("Team configuration is up to date.")
|
|
86
|
+
return 0
|
|
87
|
+
print("Created team configuration:")
|
|
88
|
+
for path in created:
|
|
89
|
+
print(f"- {path.relative_to(root)}")
|
|
90
|
+
return 0
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _doctor(root: Path, flow: str | None) -> int:
|
|
94
|
+
config = load_config(root)
|
|
95
|
+
ensure_repo(root)
|
|
96
|
+
missing: list[str] = []
|
|
97
|
+
warnings: list[str] = []
|
|
98
|
+
seen: set[str] = set()
|
|
99
|
+
for step in config.flow_steps(flow):
|
|
100
|
+
if step.get("type") != "agent":
|
|
101
|
+
if step.get("type") == "command":
|
|
102
|
+
group = str(step.get("command_group"))
|
|
103
|
+
commands = config.command_group(group)
|
|
104
|
+
if not commands:
|
|
105
|
+
warnings.append(f"command group `{group}` is empty")
|
|
106
|
+
for command in commands:
|
|
107
|
+
executable = _first_command_token(command)
|
|
108
|
+
if executable and shutil.which(executable) is None:
|
|
109
|
+
missing.append(f"command group `{group}`: {executable}")
|
|
110
|
+
continue
|
|
111
|
+
agent_name = str(step.get("agent"))
|
|
112
|
+
if agent_name in seen:
|
|
113
|
+
continue
|
|
114
|
+
seen.add(agent_name)
|
|
115
|
+
command = str(config.agent(agent_name).get("command", agent_name))
|
|
116
|
+
if shutil.which(command) is None:
|
|
117
|
+
missing.append(f"{agent_name}: {command}")
|
|
118
|
+
if missing:
|
|
119
|
+
print("Missing agent commands:")
|
|
120
|
+
for item in missing:
|
|
121
|
+
print(f"- {item}")
|
|
122
|
+
return 1
|
|
123
|
+
if warnings:
|
|
124
|
+
print("Doctor passed with warnings:")
|
|
125
|
+
for item in warnings:
|
|
126
|
+
print(f"- {item}")
|
|
127
|
+
return 0
|
|
128
|
+
print("Doctor passed")
|
|
129
|
+
return 0
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _read_task(args: argparse.Namespace) -> str:
|
|
133
|
+
if args.file:
|
|
134
|
+
return Path(args.file).read_text().strip()
|
|
135
|
+
if args.task:
|
|
136
|
+
return args.task.strip()
|
|
137
|
+
raise ValueError("Provide a task string or --file")
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _load_state(root: Path, run_id: str | None) -> RunState:
|
|
141
|
+
run_dir = root / ".team" / "runs" / run_id if run_id else latest_run_dir(root)
|
|
142
|
+
return RunState.from_path(run_dir / "state.json")
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _logs(root: Path, run_id: str | None, step: str | None) -> int:
|
|
146
|
+
run_dir = root / ".team" / "runs" / run_id if run_id else latest_run_dir(root)
|
|
147
|
+
state = RunState.from_path(run_dir / "state.json")
|
|
148
|
+
matches = state.steps
|
|
149
|
+
if step:
|
|
150
|
+
matches = [item for item in matches if item.get("id") == step]
|
|
151
|
+
if not matches:
|
|
152
|
+
print("No matching logs")
|
|
153
|
+
return 1
|
|
154
|
+
for item in matches:
|
|
155
|
+
path_value = item.get("raw_log") or item.get("log") or item.get("output")
|
|
156
|
+
if not path_value:
|
|
157
|
+
continue
|
|
158
|
+
path = root / str(path_value)
|
|
159
|
+
if not path.exists():
|
|
160
|
+
print(f"Missing log artifact: {path.relative_to(root)}")
|
|
161
|
+
continue
|
|
162
|
+
print(f"==> {path.relative_to(root)} <==")
|
|
163
|
+
content = path.read_text(errors="replace")
|
|
164
|
+
print(content, end="")
|
|
165
|
+
if not content.endswith("\n"):
|
|
166
|
+
print()
|
|
167
|
+
return 0
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _accept(root: Path, commit: bool, message: str | None) -> int:
|
|
171
|
+
state = _load_state(root, None)
|
|
172
|
+
if not commit:
|
|
173
|
+
print(f"Run {state.run_id} accepted. Changes remain in the working tree.")
|
|
174
|
+
return 0
|
|
175
|
+
paths = [path for path in changed_files(root) if not path.startswith(".team/")]
|
|
176
|
+
if not paths:
|
|
177
|
+
print(f"Run {state.run_id} has no source changes to commit.")
|
|
178
|
+
return 0
|
|
179
|
+
add_and_commit(root, paths, message or f"team: accept run {state.run_id}")
|
|
180
|
+
print(f"Committed run {state.run_id}")
|
|
181
|
+
return 0
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _reset(root: Path, run_id: str | None) -> int:
|
|
185
|
+
state = _load_state(root, run_id)
|
|
186
|
+
paths = [path for path in state.changed_files if not path.startswith(".team/")]
|
|
187
|
+
if not paths:
|
|
188
|
+
print(f"Run {state.run_id} recorded no source files to reset.")
|
|
189
|
+
return 0
|
|
190
|
+
restore_paths(root, paths)
|
|
191
|
+
for path in paths:
|
|
192
|
+
full_path = root / path
|
|
193
|
+
if full_path.exists() and path in changed_files(root):
|
|
194
|
+
try:
|
|
195
|
+
full_path.unlink()
|
|
196
|
+
except IsADirectoryError:
|
|
197
|
+
pass
|
|
198
|
+
print(f"Reset files recorded for run {state.run_id}")
|
|
199
|
+
return 0
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _first_command_token(command: str) -> str | None:
|
|
203
|
+
try:
|
|
204
|
+
parts = shlex.split(command)
|
|
205
|
+
except ValueError:
|
|
206
|
+
return None
|
|
207
|
+
if not parts:
|
|
208
|
+
return None
|
|
209
|
+
if any(token in parts[0] for token in ("=", "/", "\\")):
|
|
210
|
+
return None
|
|
211
|
+
return parts[0]
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _format_state(state: RunState) -> str:
|
|
215
|
+
changed = ", ".join(state.changed_files) if state.changed_files else "none"
|
|
216
|
+
reason = f"\nReason: {state.reason}" if state.reason else ""
|
|
217
|
+
return (
|
|
218
|
+
f"Run: {state.run_id}\n"
|
|
219
|
+
f"Flow: {state.flow}\n"
|
|
220
|
+
f"Status: {state.status}\n"
|
|
221
|
+
f"Branch: {state.branch}\n"
|
|
222
|
+
f"Changed files: {changed}"
|
|
223
|
+
f"{reason}"
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
if __name__ == "__main__":
|
|
228
|
+
raise SystemExit(main())
|
codemate/commands.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def run_command_group(
|
|
8
|
+
root: Path, commands: list[str], log_path: Path, timeout_seconds: int = 900
|
|
9
|
+
) -> tuple[bool, str | None, int]:
|
|
10
|
+
if not commands:
|
|
11
|
+
log_path.write_text("No commands configured.\n")
|
|
12
|
+
return True, None, 0
|
|
13
|
+
|
|
14
|
+
with log_path.open("w") as log:
|
|
15
|
+
for command in commands:
|
|
16
|
+
log.write(f"$ {command}\n")
|
|
17
|
+
log.flush()
|
|
18
|
+
try:
|
|
19
|
+
result = subprocess.run(
|
|
20
|
+
command,
|
|
21
|
+
cwd=root,
|
|
22
|
+
shell=True,
|
|
23
|
+
text=True,
|
|
24
|
+
stdout=log,
|
|
25
|
+
stderr=subprocess.STDOUT,
|
|
26
|
+
timeout=timeout_seconds,
|
|
27
|
+
check=False,
|
|
28
|
+
)
|
|
29
|
+
exit_code = result.returncode
|
|
30
|
+
except subprocess.TimeoutExpired:
|
|
31
|
+
exit_code = 124
|
|
32
|
+
log.write(f"\n[timeout after {timeout_seconds} seconds]\n")
|
|
33
|
+
log.write(f"\n[exit_code={exit_code}]\n")
|
|
34
|
+
if exit_code != 0:
|
|
35
|
+
return False, command, exit_code
|
|
36
|
+
return True, None, 0
|