codemate-team 0.1.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.
- codemate_team-0.1.0/LICENSE +21 -0
- codemate_team-0.1.0/PKG-INFO +132 -0
- codemate_team-0.1.0/README.md +107 -0
- codemate_team-0.1.0/codemate/__init__.py +3 -0
- codemate_team-0.1.0/codemate/agents.py +141 -0
- codemate_team-0.1.0/codemate/artifacts.py +125 -0
- codemate_team-0.1.0/codemate/cli.py +228 -0
- codemate_team-0.1.0/codemate/commands.py +36 -0
- codemate_team-0.1.0/codemate/config.py +433 -0
- codemate_team-0.1.0/codemate/git_tools.py +102 -0
- codemate_team-0.1.0/codemate/policy.py +34 -0
- codemate_team-0.1.0/codemate/workflow.py +402 -0
- codemate_team-0.1.0/codemate/yaml_lite.py +168 -0
- codemate_team-0.1.0/codemate_team.egg-info/PKG-INFO +132 -0
- codemate_team-0.1.0/codemate_team.egg-info/SOURCES.txt +25 -0
- codemate_team-0.1.0/codemate_team.egg-info/dependency_links.txt +1 -0
- codemate_team-0.1.0/codemate_team.egg-info/entry_points.txt +2 -0
- codemate_team-0.1.0/codemate_team.egg-info/top_level.txt +1 -0
- codemate_team-0.1.0/pyproject.toml +47 -0
- codemate_team-0.1.0/setup.cfg +4 -0
- codemate_team-0.1.0/tests/test_commands.py +30 -0
- codemate_team-0.1.0/tests/test_config.py +64 -0
- codemate_team-0.1.0/tests/test_git_tools.py +42 -0
- codemate_team-0.1.0/tests/test_policy.py +18 -0
- codemate_team-0.1.0/tests/test_review.py +52 -0
- codemate_team-0.1.0/tests/test_workflow.py +311 -0
- codemate_team-0.1.0/tests/test_yaml_lite.py +27 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ABHINAV UNNIKRISHNAN
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: codemate-team
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A deterministic supervisor that runs Claude Code and Codex CLI through a Plan -> Implement -> Review -> Test workflow
|
|
5
|
+
Author: Abhinav
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/abhinav-18max/codemate
|
|
8
|
+
Project-URL: Repository, https://github.com/abhinav-18max/codemate
|
|
9
|
+
Project-URL: Issues, https://github.com/abhinav-18max/codemate/issues
|
|
10
|
+
Keywords: ai,agents,claude,codex,cli,developer-tools,automation,code-review
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Topic :: Software Development :: Build Tools
|
|
19
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
20
|
+
Classifier: Topic :: Utilities
|
|
21
|
+
Requires-Python: >=3.12
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
License-File: LICENSE
|
|
24
|
+
Dynamic: license-file
|
|
25
|
+
|
|
26
|
+
# codemate
|
|
27
|
+
|
|
28
|
+
`codemate` is a sequential AI development team runner.
|
|
29
|
+
|
|
30
|
+
It does not replace Codex CLI or Claude Code. It supervises them:
|
|
31
|
+
|
|
32
|
+
```text
|
|
33
|
+
Plan -> Implement -> Review -> Test
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
The CLI owns sequencing, git state, run artifacts, local test execution, and
|
|
37
|
+
policy checks. Agent CLIs only receive one bounded role at a time.
|
|
38
|
+
|
|
39
|
+
## Install
|
|
40
|
+
|
|
41
|
+
The PyPI distribution is `codemate-team`; it installs a `codemate` command.
|
|
42
|
+
|
|
43
|
+
As a global CLI tool (recommended):
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
uv tool install codemate-team
|
|
47
|
+
# or: pipx install codemate-team
|
|
48
|
+
codemate --help
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Run once without installing:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
uvx --from codemate-team codemate --help
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
For local development from a checkout:
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
uv run --with-editable . codemate --help
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Quick Start
|
|
64
|
+
|
|
65
|
+
Initialize a repo:
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
codemate init
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Review and edit `team.yml`, then check local prerequisites:
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
codemate doctor
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Run a task:
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
codemate run "Fix the flaky checkout test"
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Inspect results:
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
codemate status
|
|
87
|
+
codemate logs --step implement
|
|
88
|
+
codemate diff
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Accept or reset:
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
codemate accept --commit --message "Fix flaky checkout test"
|
|
95
|
+
codemate reset
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## Generated Project Files
|
|
99
|
+
|
|
100
|
+
`codemate init` creates:
|
|
101
|
+
|
|
102
|
+
```text
|
|
103
|
+
team.yml
|
|
104
|
+
.team/
|
|
105
|
+
prompts/
|
|
106
|
+
schemas/
|
|
107
|
+
runs/
|
|
108
|
+
docs/
|
|
109
|
+
team.md
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
`.team/runs/` and `.team/lock.json` are ignored so run artifacts do not pollute
|
|
113
|
+
normal source control status.
|
|
114
|
+
|
|
115
|
+
## Safety Model
|
|
116
|
+
|
|
117
|
+
- The default config requires a clean worktree before a run starts.
|
|
118
|
+
- A run uses a branch under `ai/team/<run-id>`.
|
|
119
|
+
- Agent writes are checked against `policies.allow_paths` and
|
|
120
|
+
`policies.deny_paths`.
|
|
121
|
+
- Local command output is recorded by the CLI.
|
|
122
|
+
- Agent claims about tests are ignored; configured commands are the authority.
|
|
123
|
+
- Fix loops are bounded by `limits.max_fix_retries`.
|
|
124
|
+
- The CLI never pushes or merges by default.
|
|
125
|
+
|
|
126
|
+
## Documentation
|
|
127
|
+
|
|
128
|
+
- [Overview](docs/README.md)
|
|
129
|
+
- [Architecture](docs/architecture.md)
|
|
130
|
+
- [Commands](docs/commands.md)
|
|
131
|
+
- [Configuration](docs/configuration.md)
|
|
132
|
+
- [Safety](docs/safety.md)
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# codemate
|
|
2
|
+
|
|
3
|
+
`codemate` is a sequential AI development team runner.
|
|
4
|
+
|
|
5
|
+
It does not replace Codex CLI or Claude Code. It supervises them:
|
|
6
|
+
|
|
7
|
+
```text
|
|
8
|
+
Plan -> Implement -> Review -> Test
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
The CLI owns sequencing, git state, run artifacts, local test execution, and
|
|
12
|
+
policy checks. Agent CLIs only receive one bounded role at a time.
|
|
13
|
+
|
|
14
|
+
## Install
|
|
15
|
+
|
|
16
|
+
The PyPI distribution is `codemate-team`; it installs a `codemate` command.
|
|
17
|
+
|
|
18
|
+
As a global CLI tool (recommended):
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
uv tool install codemate-team
|
|
22
|
+
# or: pipx install codemate-team
|
|
23
|
+
codemate --help
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Run once without installing:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
uvx --from codemate-team codemate --help
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
For local development from a checkout:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
uv run --with-editable . codemate --help
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Quick Start
|
|
39
|
+
|
|
40
|
+
Initialize a repo:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
codemate init
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Review and edit `team.yml`, then check local prerequisites:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
codemate doctor
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Run a task:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
codemate run "Fix the flaky checkout test"
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Inspect results:
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
codemate status
|
|
62
|
+
codemate logs --step implement
|
|
63
|
+
codemate diff
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Accept or reset:
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
codemate accept --commit --message "Fix flaky checkout test"
|
|
70
|
+
codemate reset
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Generated Project Files
|
|
74
|
+
|
|
75
|
+
`codemate init` creates:
|
|
76
|
+
|
|
77
|
+
```text
|
|
78
|
+
team.yml
|
|
79
|
+
.team/
|
|
80
|
+
prompts/
|
|
81
|
+
schemas/
|
|
82
|
+
runs/
|
|
83
|
+
docs/
|
|
84
|
+
team.md
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
`.team/runs/` and `.team/lock.json` are ignored so run artifacts do not pollute
|
|
88
|
+
normal source control status.
|
|
89
|
+
|
|
90
|
+
## Safety Model
|
|
91
|
+
|
|
92
|
+
- The default config requires a clean worktree before a run starts.
|
|
93
|
+
- A run uses a branch under `ai/team/<run-id>`.
|
|
94
|
+
- Agent writes are checked against `policies.allow_paths` and
|
|
95
|
+
`policies.deny_paths`.
|
|
96
|
+
- Local command output is recorded by the CLI.
|
|
97
|
+
- Agent claims about tests are ignored; configured commands are the authority.
|
|
98
|
+
- Fix loops are bounded by `limits.max_fix_retries`.
|
|
99
|
+
- The CLI never pushes or merges by default.
|
|
100
|
+
|
|
101
|
+
## Documentation
|
|
102
|
+
|
|
103
|
+
- [Overview](docs/README.md)
|
|
104
|
+
- [Architecture](docs/architecture.md)
|
|
105
|
+
- [Commands](docs/commands.md)
|
|
106
|
+
- [Configuration](docs/configuration.md)
|
|
107
|
+
- [Safety](docs/safety.md)
|
|
@@ -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}")
|
|
@@ -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()
|