sibyl-cli 0.2.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.
- parallel_developer/__init__.py +5 -0
- parallel_developer/cli.py +649 -0
- parallel_developer/controller/__init__.py +1398 -0
- parallel_developer/controller/commands.py +132 -0
- parallel_developer/controller/events.py +17 -0
- parallel_developer/controller/flow.py +43 -0
- parallel_developer/controller/history.py +70 -0
- parallel_developer/controller/pause.py +94 -0
- parallel_developer/controller/workflow_runner.py +135 -0
- parallel_developer/orchestrator.py +1234 -0
- parallel_developer/services/__init__.py +14 -0
- parallel_developer/services/codex_monitor.py +627 -0
- parallel_developer/services/log_manager.py +161 -0
- parallel_developer/services/tmux_manager.py +245 -0
- parallel_developer/services/worktree_manager.py +119 -0
- parallel_developer/stores/__init__.py +20 -0
- parallel_developer/stores/session_manifest.py +165 -0
- parallel_developer/stores/settings_store.py +242 -0
- parallel_developer/ui/widgets.py +269 -0
- sibyl_cli-0.2.0.dist-info/METADATA +15 -0
- sibyl_cli-0.2.0.dist-info/RECORD +23 -0
- sibyl_cli-0.2.0.dist-info/WHEEL +4 -0
- sibyl_cli-0.2.0.dist-info/entry_points.txt +4 -0
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""オーケストレーションログを集約するサービス."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Dict, List, Mapping
|
|
9
|
+
|
|
10
|
+
import yaml
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class LogManager:
|
|
14
|
+
"""各サイクルのメタデータを YAML/JSONL として永続化する."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, logs_dir: Path) -> None:
|
|
17
|
+
self.logs_dir = Path(logs_dir)
|
|
18
|
+
self.logs_dir.mkdir(parents=True, exist_ok=True)
|
|
19
|
+
self.cycles_dir = self.logs_dir / "cycles"
|
|
20
|
+
self.cycles_dir.mkdir(parents=True, exist_ok=True)
|
|
21
|
+
|
|
22
|
+
def record_cycle(
|
|
23
|
+
self,
|
|
24
|
+
*,
|
|
25
|
+
instruction: str,
|
|
26
|
+
layout: Mapping[str, Any],
|
|
27
|
+
fork_map: Mapping[str, str],
|
|
28
|
+
completion: Mapping[str, Any],
|
|
29
|
+
result: Any,
|
|
30
|
+
) -> Dict[str, Path]:
|
|
31
|
+
timestamp = datetime.utcnow().strftime("%y-%m-%d-%H%M%S")
|
|
32
|
+
payload = {
|
|
33
|
+
"instruction": instruction,
|
|
34
|
+
"layout": dict(layout),
|
|
35
|
+
"fork_map": dict(fork_map),
|
|
36
|
+
"completion": dict(completion),
|
|
37
|
+
"result": {
|
|
38
|
+
"selected_session": getattr(result, "selected_session", None),
|
|
39
|
+
"sessions_summary": getattr(result, "sessions_summary", None),
|
|
40
|
+
},
|
|
41
|
+
}
|
|
42
|
+
outcome = getattr(result, "merge_outcome", None)
|
|
43
|
+
if outcome is not None:
|
|
44
|
+
payload["result"]["merge_outcome"] = {
|
|
45
|
+
"strategy": getattr(outcome, "strategy", None).value if getattr(outcome, "strategy", None) else None,
|
|
46
|
+
"status": getattr(outcome, "status", None),
|
|
47
|
+
"branch": getattr(outcome, "branch", None),
|
|
48
|
+
"error": getattr(outcome, "error", None),
|
|
49
|
+
"reason": getattr(outcome, "reason", None),
|
|
50
|
+
}
|
|
51
|
+
path = self.cycles_dir / f"{timestamp}.yaml"
|
|
52
|
+
path.write_text(
|
|
53
|
+
yaml.safe_dump(payload, sort_keys=False),
|
|
54
|
+
encoding="utf-8",
|
|
55
|
+
)
|
|
56
|
+
jsonl_path = self.cycles_dir / f"{timestamp}.jsonl"
|
|
57
|
+
events = self._build_cycle_events(
|
|
58
|
+
instruction=instruction,
|
|
59
|
+
layout=layout,
|
|
60
|
+
fork_map=fork_map,
|
|
61
|
+
completion=completion,
|
|
62
|
+
result=result,
|
|
63
|
+
)
|
|
64
|
+
with jsonl_path.open("w", encoding="utf-8") as fh:
|
|
65
|
+
for event in events:
|
|
66
|
+
fh.write(json.dumps(event, ensure_ascii=False) + "\n")
|
|
67
|
+
return {"yaml": path, "jsonl": jsonl_path}
|
|
68
|
+
|
|
69
|
+
def _build_cycle_events(
|
|
70
|
+
self,
|
|
71
|
+
*,
|
|
72
|
+
instruction: str,
|
|
73
|
+
layout: Mapping[str, Any],
|
|
74
|
+
fork_map: Mapping[str, str],
|
|
75
|
+
completion: Mapping[str, Any],
|
|
76
|
+
result: Any,
|
|
77
|
+
) -> List[Dict[str, Any]]:
|
|
78
|
+
timestamp = datetime.utcnow().isoformat(timespec="seconds")
|
|
79
|
+
events: List[Dict[str, Any]] = []
|
|
80
|
+
|
|
81
|
+
events.append(
|
|
82
|
+
{
|
|
83
|
+
"type": "instruction",
|
|
84
|
+
"timestamp": timestamp,
|
|
85
|
+
"instruction": instruction,
|
|
86
|
+
"layout": dict(layout),
|
|
87
|
+
}
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
if fork_map:
|
|
91
|
+
events.append(
|
|
92
|
+
{
|
|
93
|
+
"type": "fork",
|
|
94
|
+
"timestamp": timestamp,
|
|
95
|
+
"fork_map": dict(fork_map),
|
|
96
|
+
}
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
if completion:
|
|
100
|
+
events.append(
|
|
101
|
+
{
|
|
102
|
+
"type": "completion",
|
|
103
|
+
"timestamp": timestamp,
|
|
104
|
+
"completion": dict(completion),
|
|
105
|
+
}
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
scoreboard = getattr(result, "sessions_summary", None) or {}
|
|
109
|
+
if scoreboard:
|
|
110
|
+
events.append(
|
|
111
|
+
{
|
|
112
|
+
"type": "scoreboard",
|
|
113
|
+
"timestamp": timestamp,
|
|
114
|
+
"scoreboard": scoreboard,
|
|
115
|
+
}
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
selected_session = getattr(result, "selected_session", None)
|
|
119
|
+
selected_key = None
|
|
120
|
+
for key, data in scoreboard.items():
|
|
121
|
+
if data.get("selected"):
|
|
122
|
+
selected_key = key
|
|
123
|
+
break
|
|
124
|
+
events.append(
|
|
125
|
+
{
|
|
126
|
+
"type": "selection",
|
|
127
|
+
"timestamp": timestamp,
|
|
128
|
+
"selected_session": selected_session,
|
|
129
|
+
"selected_key": selected_key,
|
|
130
|
+
}
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
outcome = getattr(result, "merge_outcome", None)
|
|
134
|
+
if outcome is not None:
|
|
135
|
+
events.append(
|
|
136
|
+
{
|
|
137
|
+
"type": "merge_outcome",
|
|
138
|
+
"timestamp": timestamp,
|
|
139
|
+
"strategy": getattr(outcome, "strategy", None).value if getattr(outcome, "strategy", None) else None,
|
|
140
|
+
"status": getattr(outcome, "status", None),
|
|
141
|
+
"branch": getattr(outcome, "branch", None),
|
|
142
|
+
"error": getattr(outcome, "error", None),
|
|
143
|
+
"reason": getattr(outcome, "reason", None),
|
|
144
|
+
}
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
artifact = getattr(result, "artifact", None)
|
|
148
|
+
if artifact is not None:
|
|
149
|
+
events.append(
|
|
150
|
+
{
|
|
151
|
+
"type": "artifact",
|
|
152
|
+
"timestamp": timestamp,
|
|
153
|
+
"main_session_id": getattr(artifact, "main_session_id", None),
|
|
154
|
+
"worker_sessions": getattr(artifact, "worker_sessions", {}),
|
|
155
|
+
"boss_session_id": getattr(artifact, "boss_session_id", None),
|
|
156
|
+
"worker_paths": {k: str(v) for k, v in getattr(artifact, "worker_paths", {}).items()},
|
|
157
|
+
"boss_path": str(getattr(artifact, "boss_path", "")) if getattr(artifact, "boss_path", None) else None,
|
|
158
|
+
}
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
return events
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"""tmux レイアウトとキーストロークを制御するサービス."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import shlex
|
|
6
|
+
import time
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Iterable, Mapping, Optional, Sequence, TYPE_CHECKING, List
|
|
9
|
+
|
|
10
|
+
import libtmux
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING: # pragma: no cover - 型チェック専用
|
|
13
|
+
from .codex_monitor import CodexMonitor
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class TmuxLayoutManager:
|
|
17
|
+
"""Parallel Codex 用の tmux セッションを構成・制御する."""
|
|
18
|
+
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
session_name: str,
|
|
22
|
+
worker_count: int,
|
|
23
|
+
monitor: "CodexMonitor",
|
|
24
|
+
*,
|
|
25
|
+
root_path: Path,
|
|
26
|
+
startup_delay: float = 0.0,
|
|
27
|
+
backtrack_delay: float = 0.05,
|
|
28
|
+
reuse_existing_session: bool = False,
|
|
29
|
+
session_namespace: Optional[str] = None,
|
|
30
|
+
) -> None:
|
|
31
|
+
self.session_name = session_name
|
|
32
|
+
self.worker_count = worker_count
|
|
33
|
+
self.monitor = monitor
|
|
34
|
+
self.root_path = Path(root_path)
|
|
35
|
+
self.boss_path = self.root_path
|
|
36
|
+
self.startup_delay = startup_delay
|
|
37
|
+
self.backtrack_delay = backtrack_delay
|
|
38
|
+
self.reuse_existing_session = reuse_existing_session
|
|
39
|
+
self.session_namespace = session_namespace
|
|
40
|
+
self._server = libtmux.Server()
|
|
41
|
+
|
|
42
|
+
def set_boss_path(self, path: Path) -> None:
|
|
43
|
+
self.boss_path = Path(path)
|
|
44
|
+
|
|
45
|
+
def set_reuse_existing_session(self, reuse: bool) -> None:
|
|
46
|
+
self.reuse_existing_session = reuse
|
|
47
|
+
|
|
48
|
+
def ensure_layout(self, *, session_name: str, worker_count: int) -> dict[str, Any]:
|
|
49
|
+
if session_name != self.session_name or worker_count != self.worker_count:
|
|
50
|
+
raise ValueError("session_name/worker_count mismatch with manager configuration")
|
|
51
|
+
|
|
52
|
+
session = self._get_or_create_session(fresh=not self.reuse_existing_session)
|
|
53
|
+
window = getattr(session, "attached_window", None) or session.windows[0]
|
|
54
|
+
|
|
55
|
+
target_pane_count = self.worker_count + 2 # main + boss + workers
|
|
56
|
+
while len(window.panes) < target_pane_count:
|
|
57
|
+
self._split_largest_pane(window)
|
|
58
|
+
window.select_layout("tiled")
|
|
59
|
+
window = getattr(session, "attached_window", None) or session.windows[0]
|
|
60
|
+
window.select_layout("tiled")
|
|
61
|
+
|
|
62
|
+
panes = window.panes
|
|
63
|
+
layout = {
|
|
64
|
+
"main": panes[0].pane_id,
|
|
65
|
+
"boss": panes[1].pane_id,
|
|
66
|
+
"workers": [pane.pane_id for pane in panes[2 : 2 + self.worker_count]],
|
|
67
|
+
}
|
|
68
|
+
self._apply_role_labels(session, layout)
|
|
69
|
+
return layout
|
|
70
|
+
|
|
71
|
+
def launch_main_session(self, *, pane_id: str) -> None:
|
|
72
|
+
codex = self._codex_command("codex")
|
|
73
|
+
command = f"cd {shlex.quote(str(self.root_path))} && {codex}"
|
|
74
|
+
self._send_command(pane_id, command)
|
|
75
|
+
self._maybe_wait()
|
|
76
|
+
|
|
77
|
+
def resume_session(self, *, pane_id: str, workdir: Path, session_id: str) -> None:
|
|
78
|
+
codex = self._codex_command(f"codex resume {shlex.quote(str(session_id))}")
|
|
79
|
+
command = f"cd {shlex.quote(str(workdir))} && {codex}"
|
|
80
|
+
self._send_command(pane_id, command)
|
|
81
|
+
self._maybe_wait()
|
|
82
|
+
|
|
83
|
+
def fork_boss(self, *, pane_id: str, base_session_id: str, boss_path: Path) -> None:
|
|
84
|
+
self.interrupt_pane(pane_id=pane_id)
|
|
85
|
+
command = f"cd {shlex.quote(str(boss_path))} && {self._codex_command(f'codex resume {shlex.quote(str(base_session_id))}')}"
|
|
86
|
+
self._send_command(pane_id, command)
|
|
87
|
+
self._maybe_wait()
|
|
88
|
+
pane = self._get_pane(pane_id)
|
|
89
|
+
pane.send_keys("C-[", enter=False)
|
|
90
|
+
time.sleep(self.backtrack_delay)
|
|
91
|
+
pane.send_keys("C-[", enter=False)
|
|
92
|
+
time.sleep(self.backtrack_delay)
|
|
93
|
+
pane.send_keys("", enter=True)
|
|
94
|
+
time.sleep(self.backtrack_delay)
|
|
95
|
+
self._maybe_wait()
|
|
96
|
+
|
|
97
|
+
def fork_workers(
|
|
98
|
+
self,
|
|
99
|
+
*,
|
|
100
|
+
workers: Iterable[str],
|
|
101
|
+
base_session_id: str,
|
|
102
|
+
pane_paths: Mapping[str, Path],
|
|
103
|
+
) -> List[str]:
|
|
104
|
+
if not base_session_id:
|
|
105
|
+
raise RuntimeError("base_session_id が空です。メインセッションのIDが取得できていません。")
|
|
106
|
+
worker_list = list(workers)
|
|
107
|
+
for pane_id in worker_list:
|
|
108
|
+
try:
|
|
109
|
+
worker_path = Path(pane_paths[pane_id])
|
|
110
|
+
except KeyError as exc:
|
|
111
|
+
raise RuntimeError(f"pane {pane_id!r} に対応するワークツリーパスがありません") from exc
|
|
112
|
+
self.interrupt_pane(pane_id=pane_id)
|
|
113
|
+
command = f"cd {shlex.quote(str(worker_path))} && {self._codex_command(f'codex resume {shlex.quote(str(base_session_id))}')}"
|
|
114
|
+
self._send_command(pane_id, command)
|
|
115
|
+
self._maybe_wait()
|
|
116
|
+
if worker_list:
|
|
117
|
+
self._broadcast_keys(worker_list, "C-[", enter=False)
|
|
118
|
+
self._broadcast_keys(worker_list, "C-[", enter=False)
|
|
119
|
+
for pane_id in worker_list:
|
|
120
|
+
pane = self._get_pane(pane_id)
|
|
121
|
+
pane.send_keys("", enter=True)
|
|
122
|
+
self._maybe_wait()
|
|
123
|
+
return worker_list
|
|
124
|
+
|
|
125
|
+
def send_instruction_to_pane(self, *, pane_id: str, instruction: str) -> None:
|
|
126
|
+
self._send_text(pane_id, instruction)
|
|
127
|
+
|
|
128
|
+
def prepare_for_instruction(self, *, pane_id: str) -> None:
|
|
129
|
+
pane = self._get_pane(pane_id)
|
|
130
|
+
pane.send_keys("C-c", enter=False)
|
|
131
|
+
if self.backtrack_delay > 0:
|
|
132
|
+
time.sleep(self.backtrack_delay)
|
|
133
|
+
|
|
134
|
+
def promote_to_main(self, *, session_id: str, pane_id: str) -> None:
|
|
135
|
+
command = self._codex_command(f"codex resume {shlex.quote(str(session_id))}")
|
|
136
|
+
if self.backtrack_delay > 0:
|
|
137
|
+
time.sleep(self.backtrack_delay)
|
|
138
|
+
self._send_command(pane_id, command)
|
|
139
|
+
|
|
140
|
+
def interrupt_pane(self, *, pane_id: str) -> None:
|
|
141
|
+
pane = self._get_pane(pane_id)
|
|
142
|
+
pane.send_keys("C-c", enter=False)
|
|
143
|
+
if self.backtrack_delay > 0:
|
|
144
|
+
time.sleep(self.backtrack_delay)
|
|
145
|
+
pane.send_keys("C-c", enter=False)
|
|
146
|
+
if self.backtrack_delay > 0:
|
|
147
|
+
time.sleep(self.backtrack_delay)
|
|
148
|
+
|
|
149
|
+
def _apply_role_labels(self, session, layout: Mapping[str, Any]) -> None:
|
|
150
|
+
try:
|
|
151
|
+
self._set_pane_title(session, layout.get("main"), "MAIN")
|
|
152
|
+
self._set_pane_title(session, layout.get("boss"), "BOSS")
|
|
153
|
+
for index, pane_id in enumerate(layout.get("workers", []), start=1):
|
|
154
|
+
self._set_pane_title(session, pane_id, f"WORKER-{index}")
|
|
155
|
+
except Exception: # pragma: no cover - tmux互換対策
|
|
156
|
+
pass
|
|
157
|
+
|
|
158
|
+
def _set_pane_title(self, session, pane_id: Optional[str], title: str) -> None:
|
|
159
|
+
if not pane_id:
|
|
160
|
+
return
|
|
161
|
+
try:
|
|
162
|
+
session.cmd("select-pane", "-t", pane_id, "-T", title)
|
|
163
|
+
except Exception: # pragma: no cover - 古いtmux向け
|
|
164
|
+
pass
|
|
165
|
+
|
|
166
|
+
def _split_largest_pane(self, window) -> None:
|
|
167
|
+
panes = list(window.panes)
|
|
168
|
+
if not panes:
|
|
169
|
+
window.split_window(attach=False)
|
|
170
|
+
return
|
|
171
|
+
largest = max(panes, key=lambda pane: int(pane.height) * int(pane.width))
|
|
172
|
+
window.select_pane(largest.pane_id)
|
|
173
|
+
window.split_window(attach=False)
|
|
174
|
+
|
|
175
|
+
def _get_or_create_session(self, fresh: bool = False):
|
|
176
|
+
session = self._server.find_where({"session_name": self.session_name})
|
|
177
|
+
if session is not None and not fresh:
|
|
178
|
+
self._configure_session(session)
|
|
179
|
+
return session
|
|
180
|
+
|
|
181
|
+
kill_existing = fresh and session is not None
|
|
182
|
+
session = self._server.new_session(
|
|
183
|
+
session_name=self.session_name,
|
|
184
|
+
attach=False,
|
|
185
|
+
kill_session=kill_existing,
|
|
186
|
+
)
|
|
187
|
+
self._configure_session(session)
|
|
188
|
+
return session
|
|
189
|
+
|
|
190
|
+
def _get_pane(self, pane_id: str):
|
|
191
|
+
pane = self._find_pane(pane_id)
|
|
192
|
+
if pane is not None:
|
|
193
|
+
return pane
|
|
194
|
+
self._server = libtmux.Server()
|
|
195
|
+
pane = self._find_pane(pane_id)
|
|
196
|
+
if pane is not None:
|
|
197
|
+
return pane
|
|
198
|
+
raise RuntimeError(f"Pane {pane_id!r} not found in tmux session {self.session_name}")
|
|
199
|
+
|
|
200
|
+
def _find_pane(self, pane_id: str):
|
|
201
|
+
for session in getattr(self._server, "sessions", []):
|
|
202
|
+
for window in getattr(session, "windows", []):
|
|
203
|
+
for pane in getattr(window, "panes", []):
|
|
204
|
+
if getattr(pane, "pane_id", None) == pane_id:
|
|
205
|
+
return pane
|
|
206
|
+
return None
|
|
207
|
+
|
|
208
|
+
def _send_command(self, pane_id: str, command: str) -> None:
|
|
209
|
+
pane = self._get_pane(pane_id)
|
|
210
|
+
pane.send_keys(command, enter=True)
|
|
211
|
+
|
|
212
|
+
def _maybe_wait(self) -> None:
|
|
213
|
+
if self.startup_delay > 0:
|
|
214
|
+
time.sleep(self.startup_delay)
|
|
215
|
+
|
|
216
|
+
def _send_text(self, pane_id: str, text: str) -> None:
|
|
217
|
+
pane = self._get_pane(pane_id)
|
|
218
|
+
payload = text.replace("\r\n", "\n")
|
|
219
|
+
pane.send_keys(f"\x1b[200~{payload}\x1b[201~", enter=True)
|
|
220
|
+
|
|
221
|
+
def _codex_command(self, command: str) -> str:
|
|
222
|
+
return command
|
|
223
|
+
|
|
224
|
+
def _broadcast_keys(self, pane_ids: Sequence[str], key: str, *, enter: bool) -> None:
|
|
225
|
+
for pane_id in pane_ids:
|
|
226
|
+
pane = self._get_pane(pane_id)
|
|
227
|
+
pane.send_keys(key, enter=enter)
|
|
228
|
+
if self.backtrack_delay > 0:
|
|
229
|
+
time.sleep(self.backtrack_delay)
|
|
230
|
+
|
|
231
|
+
def _configure_session(self, session) -> None:
|
|
232
|
+
commands = [
|
|
233
|
+
("set-option", "-g", "mouse", "on"),
|
|
234
|
+
("set-option", "-g", "pane-border-style", "fg=green"),
|
|
235
|
+
("set-option", "-g", "pane-active-border-style", "fg=orange"),
|
|
236
|
+
("set-option", "-g", "pane-border-status", "top"),
|
|
237
|
+
("set-option", "-g", "pane-border-format", "#{pane_title}"),
|
|
238
|
+
("set-option", "-g", "display-panes-colour", "green"),
|
|
239
|
+
("set-option", "-g", "display-panes-active-colour", "orange"),
|
|
240
|
+
]
|
|
241
|
+
for args in commands:
|
|
242
|
+
try:
|
|
243
|
+
session.cmd(*args)
|
|
244
|
+
except Exception: # pragma: no cover - 一部オプション非対応のtmux向けフォールバック
|
|
245
|
+
continue
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Git worktree を管理するサービス."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import shutil
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Dict, Optional
|
|
8
|
+
|
|
9
|
+
import git
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class WorktreeManager:
|
|
13
|
+
"""ワーカー/ボス用の git worktree を生成・整備する."""
|
|
14
|
+
|
|
15
|
+
def __init__(
|
|
16
|
+
self,
|
|
17
|
+
root: Path,
|
|
18
|
+
worker_count: int,
|
|
19
|
+
session_namespace: Optional[str] = None,
|
|
20
|
+
storage_root: Optional[Path] = None,
|
|
21
|
+
) -> None:
|
|
22
|
+
self.root = Path(root)
|
|
23
|
+
self.worker_count = worker_count
|
|
24
|
+
self.session_namespace = session_namespace
|
|
25
|
+
self._repo = git.Repo(self.root)
|
|
26
|
+
self._ensure_repo_initialized()
|
|
27
|
+
self._storage_root = Path(storage_root) if storage_root is not None else self.root
|
|
28
|
+
self._session_root = self._resolve_session_root()
|
|
29
|
+
self.worktrees_dir = self._session_root / "worktrees"
|
|
30
|
+
self.boss_path = self.worktrees_dir / "boss"
|
|
31
|
+
self._worker_branch_template, self._boss_branch = self._resolve_branch_templates()
|
|
32
|
+
self._initialized = False
|
|
33
|
+
self._worker_paths: Dict[str, Path] = {}
|
|
34
|
+
|
|
35
|
+
def prepare(self) -> Dict[str, Path]:
|
|
36
|
+
self.worktrees_dir.mkdir(parents=True, exist_ok=True)
|
|
37
|
+
mapping: Dict[str, Path] = {}
|
|
38
|
+
for index in range(1, self.worker_count + 1):
|
|
39
|
+
worker_name = f"worker-{index}"
|
|
40
|
+
worktree_path = self.worktrees_dir / worker_name
|
|
41
|
+
branch_name = self.worker_branch(worker_name)
|
|
42
|
+
if not self._initialized or worker_name not in self._worker_paths:
|
|
43
|
+
self._recreate_worktree(worktree_path, branch_name)
|
|
44
|
+
else:
|
|
45
|
+
self._reset_worktree(worktree_path)
|
|
46
|
+
mapping[worker_name] = worktree_path
|
|
47
|
+
if self._initialized:
|
|
48
|
+
existing = set(self._worker_paths)
|
|
49
|
+
target = set(mapping)
|
|
50
|
+
for obsolete in existing - target:
|
|
51
|
+
self._remove_worktree(self.worktrees_dir / obsolete)
|
|
52
|
+
|
|
53
|
+
if not self._initialized:
|
|
54
|
+
self._recreate_worktree(self.boss_path, self.boss_branch)
|
|
55
|
+
else:
|
|
56
|
+
self._reset_worktree(self.boss_path)
|
|
57
|
+
self._worker_paths = mapping
|
|
58
|
+
self._initialized = True
|
|
59
|
+
return mapping
|
|
60
|
+
|
|
61
|
+
def worker_branch(self, worker_name: str) -> str:
|
|
62
|
+
return self._worker_branch_template.format(name=worker_name)
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def boss_branch(self) -> str:
|
|
66
|
+
return self._boss_branch
|
|
67
|
+
|
|
68
|
+
def _ensure_repo_initialized(self) -> None:
|
|
69
|
+
try:
|
|
70
|
+
_ = self._repo.head.commit # type: ignore[attr-defined]
|
|
71
|
+
except ValueError as exc:
|
|
72
|
+
raise RuntimeError(
|
|
73
|
+
"Git repository has no commits. Create an initial commit before running parallel-dev."
|
|
74
|
+
) from exc
|
|
75
|
+
|
|
76
|
+
def _recreate_worktree(self, path: Path, branch_name: str) -> None:
|
|
77
|
+
if path.exists():
|
|
78
|
+
try:
|
|
79
|
+
self._repo.git.worktree("remove", "--force", str(path))
|
|
80
|
+
except git.GitCommandError:
|
|
81
|
+
shutil.rmtree(path, ignore_errors=True)
|
|
82
|
+
if path.exists():
|
|
83
|
+
shutil.rmtree(path, ignore_errors=True)
|
|
84
|
+
self._repo.git.worktree(
|
|
85
|
+
"add",
|
|
86
|
+
"-B",
|
|
87
|
+
branch_name,
|
|
88
|
+
str(path),
|
|
89
|
+
"HEAD",
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
def _reset_worktree(self, path: Path) -> None:
|
|
93
|
+
if not path.exists():
|
|
94
|
+
return
|
|
95
|
+
repo = git.Repo(path)
|
|
96
|
+
repo.git.reset("--hard", "HEAD")
|
|
97
|
+
repo.git.clean("-fdx")
|
|
98
|
+
|
|
99
|
+
def _remove_worktree(self, path: Path) -> None:
|
|
100
|
+
if not path.exists():
|
|
101
|
+
return
|
|
102
|
+
try:
|
|
103
|
+
self._repo.git.worktree("remove", "--force", str(path))
|
|
104
|
+
except git.GitCommandError:
|
|
105
|
+
shutil.rmtree(path, ignore_errors=True)
|
|
106
|
+
if path.exists():
|
|
107
|
+
shutil.rmtree(path, ignore_errors=True)
|
|
108
|
+
|
|
109
|
+
def _resolve_session_root(self) -> Path:
|
|
110
|
+
base = self._storage_root / ".parallel-dev"
|
|
111
|
+
if self.session_namespace:
|
|
112
|
+
return base / "sessions" / self.session_namespace
|
|
113
|
+
return base
|
|
114
|
+
|
|
115
|
+
def _resolve_branch_templates(self) -> tuple[str, str]:
|
|
116
|
+
if self.session_namespace:
|
|
117
|
+
prefix = f"parallel-dev/{self.session_namespace}"
|
|
118
|
+
return f"{prefix}/{{name}}", f"{prefix}/boss"
|
|
119
|
+
return "parallel-dev/{name}", "parallel-dev/boss"
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Persistence-related store helpers."""
|
|
2
|
+
|
|
3
|
+
from .session_manifest import ManifestStore, PaneRecord, SessionManifest, SessionReference
|
|
4
|
+
from .settings_store import (
|
|
5
|
+
SettingsStore,
|
|
6
|
+
default_config_dir,
|
|
7
|
+
resolve_settings_path,
|
|
8
|
+
resolve_worktree_root,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"ManifestStore",
|
|
13
|
+
"PaneRecord",
|
|
14
|
+
"SessionManifest",
|
|
15
|
+
"SessionReference",
|
|
16
|
+
"SettingsStore",
|
|
17
|
+
"default_config_dir",
|
|
18
|
+
"resolve_settings_path",
|
|
19
|
+
"resolve_worktree_root",
|
|
20
|
+
]
|