ensemble-claude 0.3.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.
- ensemble/__init__.py +5 -0
- ensemble/ack.py +86 -0
- ensemble/cli.py +31 -0
- ensemble/commands/__init__.py +1 -0
- ensemble/commands/_init_impl.py +208 -0
- ensemble/commands/_launch_impl.py +217 -0
- ensemble/commands/init.py +35 -0
- ensemble/commands/launch.py +32 -0
- ensemble/config.py +218 -0
- ensemble/dashboard.py +168 -0
- ensemble/helpers.py +79 -0
- ensemble/lock.py +77 -0
- ensemble/logger.py +80 -0
- ensemble/notes.py +221 -0
- ensemble/queue.py +166 -0
- ensemble/templates/__init__.py +75 -0
- ensemble/templates/agents/conductor.md +239 -0
- ensemble/templates/agents/dispatch.md +351 -0
- ensemble/templates/agents/integrator.md +138 -0
- ensemble/templates/agents/learner.md +133 -0
- ensemble/templates/agents/reviewer.md +84 -0
- ensemble/templates/agents/security-reviewer.md +136 -0
- ensemble/templates/agents/worker.md +184 -0
- ensemble/templates/commands/go-light.md +49 -0
- ensemble/templates/commands/go.md +101 -0
- ensemble/templates/commands/improve.md +116 -0
- ensemble/templates/commands/review.md +74 -0
- ensemble/templates/commands/status.md +56 -0
- ensemble/templates/scripts/dashboard-update.sh +78 -0
- ensemble/templates/scripts/launch.sh +137 -0
- ensemble/templates/scripts/pane-setup.sh +111 -0
- ensemble/templates/scripts/setup.sh +163 -0
- ensemble/templates/scripts/worktree-create.sh +89 -0
- ensemble/templates/scripts/worktree-merge.sh +194 -0
- ensemble/templates/workflows/default.yaml +78 -0
- ensemble/templates/workflows/heavy.yaml +149 -0
- ensemble/templates/workflows/simple.yaml +41 -0
- ensemble/templates/workflows/worktree.yaml +202 -0
- ensemble/utils.py +60 -0
- ensemble/workflow.py +127 -0
- ensemble/worktree.py +322 -0
- ensemble_claude-0.3.0.dist-info/METADATA +144 -0
- ensemble_claude-0.3.0.dist-info/RECORD +46 -0
- ensemble_claude-0.3.0.dist-info/WHEEL +4 -0
- ensemble_claude-0.3.0.dist-info/entry_points.txt +2 -0
- ensemble_claude-0.3.0.dist-info/licenses/LICENSE +21 -0
ensemble/config.py
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""Ensemble configuration management.
|
|
2
|
+
|
|
3
|
+
Handles global (~/.config/ensemble/) and local (.ensemble/) configuration.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import shutil
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Optional
|
|
9
|
+
|
|
10
|
+
import yaml
|
|
11
|
+
|
|
12
|
+
from ensemble.templates import get_template_path
|
|
13
|
+
|
|
14
|
+
# Default configuration values
|
|
15
|
+
DEFAULT_CONFIG = {
|
|
16
|
+
"version": "0.3.0",
|
|
17
|
+
"session": {
|
|
18
|
+
"name": "ensemble",
|
|
19
|
+
"attach": True,
|
|
20
|
+
},
|
|
21
|
+
"agents": {
|
|
22
|
+
"conductor": "conductor.md",
|
|
23
|
+
"dispatch": "dispatch.md",
|
|
24
|
+
"worker": "worker.md",
|
|
25
|
+
"reviewer": "reviewer.md",
|
|
26
|
+
"security_reviewer": "security-reviewer.md",
|
|
27
|
+
"integrator": "integrator.md",
|
|
28
|
+
"learner": "learner.md",
|
|
29
|
+
},
|
|
30
|
+
"workflow": {
|
|
31
|
+
"default": "default.yaml",
|
|
32
|
+
"simple": "simple.yaml",
|
|
33
|
+
"heavy": "heavy.yaml",
|
|
34
|
+
"worktree": "worktree.yaml",
|
|
35
|
+
},
|
|
36
|
+
"limits": {
|
|
37
|
+
"max_parallel_workers": 4,
|
|
38
|
+
"max_iterations": 15,
|
|
39
|
+
},
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def get_global_config_dir() -> Path:
|
|
44
|
+
"""Get the global configuration directory path."""
|
|
45
|
+
return Path.home() / ".config" / "ensemble"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def get_local_config_dir() -> Path:
|
|
49
|
+
"""Get the local configuration directory path (current project)."""
|
|
50
|
+
return Path.cwd() / ".ensemble"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def ensure_global_config() -> Path:
|
|
54
|
+
"""Ensure global configuration directory exists with defaults.
|
|
55
|
+
|
|
56
|
+
Creates ~/.config/ensemble/ with default templates if it doesn't exist.
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
Path to the global config directory
|
|
60
|
+
"""
|
|
61
|
+
global_dir = get_global_config_dir()
|
|
62
|
+
|
|
63
|
+
if not global_dir.exists():
|
|
64
|
+
global_dir.mkdir(parents=True, exist_ok=True)
|
|
65
|
+
|
|
66
|
+
# Copy default config
|
|
67
|
+
_write_default_config(global_dir / "config.yaml")
|
|
68
|
+
|
|
69
|
+
# Copy agent templates
|
|
70
|
+
_copy_default_templates(global_dir)
|
|
71
|
+
|
|
72
|
+
return global_dir
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _write_default_config(config_path: Path) -> None:
|
|
76
|
+
"""Write default configuration to file."""
|
|
77
|
+
with open(config_path, "w") as f:
|
|
78
|
+
yaml.dump(DEFAULT_CONFIG, f, default_flow_style=False, sort_keys=False)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _copy_default_templates(global_dir: Path) -> None:
|
|
82
|
+
"""Copy default templates to global config directory."""
|
|
83
|
+
# Copy agents
|
|
84
|
+
agents_dir = global_dir / "agents"
|
|
85
|
+
agents_dir.mkdir(exist_ok=True)
|
|
86
|
+
|
|
87
|
+
template_agents = get_template_path("agents")
|
|
88
|
+
if template_agents.exists():
|
|
89
|
+
for agent_file in template_agents.glob("*.md"):
|
|
90
|
+
shutil.copy(agent_file, agents_dir / agent_file.name)
|
|
91
|
+
|
|
92
|
+
# Copy workflows
|
|
93
|
+
workflows_dir = global_dir / "workflows"
|
|
94
|
+
workflows_dir.mkdir(exist_ok=True)
|
|
95
|
+
|
|
96
|
+
template_workflows = get_template_path("workflows")
|
|
97
|
+
if template_workflows.exists():
|
|
98
|
+
for workflow_file in template_workflows.glob("*.yaml"):
|
|
99
|
+
shutil.copy(workflow_file, workflows_dir / workflow_file.name)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def load_config() -> dict[str, Any]:
|
|
103
|
+
"""Load merged configuration (global + local).
|
|
104
|
+
|
|
105
|
+
Priority (highest first):
|
|
106
|
+
1. Local project config (.ensemble/config.yaml)
|
|
107
|
+
2. Global config (~/.config/ensemble/config.yaml)
|
|
108
|
+
3. Default values
|
|
109
|
+
|
|
110
|
+
Returns:
|
|
111
|
+
Merged configuration dictionary
|
|
112
|
+
"""
|
|
113
|
+
config = DEFAULT_CONFIG.copy()
|
|
114
|
+
|
|
115
|
+
# Load global config
|
|
116
|
+
global_config_file = get_global_config_dir() / "config.yaml"
|
|
117
|
+
if global_config_file.exists():
|
|
118
|
+
with open(global_config_file) as f:
|
|
119
|
+
global_config = yaml.safe_load(f) or {}
|
|
120
|
+
config = _deep_merge(config, global_config)
|
|
121
|
+
|
|
122
|
+
# Load local config (overrides global)
|
|
123
|
+
local_config_file = get_local_config_dir() / "config.yaml"
|
|
124
|
+
if local_config_file.exists():
|
|
125
|
+
with open(local_config_file) as f:
|
|
126
|
+
local_config = yaml.safe_load(f) or {}
|
|
127
|
+
config = _deep_merge(config, local_config)
|
|
128
|
+
|
|
129
|
+
return config
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _deep_merge(base: dict, override: dict) -> dict:
|
|
133
|
+
"""Deep merge two dictionaries.
|
|
134
|
+
|
|
135
|
+
Args:
|
|
136
|
+
base: Base dictionary
|
|
137
|
+
override: Dictionary with overriding values
|
|
138
|
+
|
|
139
|
+
Returns:
|
|
140
|
+
Merged dictionary
|
|
141
|
+
"""
|
|
142
|
+
result = base.copy()
|
|
143
|
+
|
|
144
|
+
for key, value in override.items():
|
|
145
|
+
if key in result and isinstance(result[key], dict) and isinstance(value, dict):
|
|
146
|
+
result[key] = _deep_merge(result[key], value)
|
|
147
|
+
else:
|
|
148
|
+
result[key] = value
|
|
149
|
+
|
|
150
|
+
return result
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def resolve_agent_path(agent_name: str) -> Optional[Path]:
|
|
154
|
+
"""Resolve the path to an agent definition file.
|
|
155
|
+
|
|
156
|
+
Priority (highest first):
|
|
157
|
+
1. Local project: ./.claude/agents/{agent}.md
|
|
158
|
+
2. Global config: ~/.config/ensemble/agents/{agent}.md
|
|
159
|
+
3. Package template: ensemble/templates/agents/{agent}.md
|
|
160
|
+
|
|
161
|
+
Args:
|
|
162
|
+
agent_name: Name of the agent (e.g., "conductor", "dispatch")
|
|
163
|
+
|
|
164
|
+
Returns:
|
|
165
|
+
Path to the agent file, or None if not found
|
|
166
|
+
"""
|
|
167
|
+
filename = f"{agent_name}.md"
|
|
168
|
+
|
|
169
|
+
# Check local
|
|
170
|
+
local_path = Path.cwd() / ".claude" / "agents" / filename
|
|
171
|
+
if local_path.exists():
|
|
172
|
+
return local_path
|
|
173
|
+
|
|
174
|
+
# Check global
|
|
175
|
+
global_path = get_global_config_dir() / "agents" / filename
|
|
176
|
+
if global_path.exists():
|
|
177
|
+
return global_path
|
|
178
|
+
|
|
179
|
+
# Check package template
|
|
180
|
+
template_path = get_template_path("agents") / filename
|
|
181
|
+
if template_path.exists():
|
|
182
|
+
return template_path
|
|
183
|
+
|
|
184
|
+
return None
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def resolve_workflow_path(workflow_name: str) -> Optional[Path]:
|
|
188
|
+
"""Resolve the path to a workflow definition file.
|
|
189
|
+
|
|
190
|
+
Priority (highest first):
|
|
191
|
+
1. Local project: ./.ensemble/workflows/{workflow}.yaml
|
|
192
|
+
2. Global config: ~/.config/ensemble/workflows/{workflow}.yaml
|
|
193
|
+
3. Package template: ensemble/templates/workflows/{workflow}.yaml
|
|
194
|
+
|
|
195
|
+
Args:
|
|
196
|
+
workflow_name: Name of the workflow (e.g., "default", "simple")
|
|
197
|
+
|
|
198
|
+
Returns:
|
|
199
|
+
Path to the workflow file, or None if not found
|
|
200
|
+
"""
|
|
201
|
+
filename = f"{workflow_name}.yaml"
|
|
202
|
+
|
|
203
|
+
# Check local
|
|
204
|
+
local_path = Path.cwd() / ".ensemble" / "workflows" / filename
|
|
205
|
+
if local_path.exists():
|
|
206
|
+
return local_path
|
|
207
|
+
|
|
208
|
+
# Check global
|
|
209
|
+
global_path = get_global_config_dir() / "workflows" / filename
|
|
210
|
+
if global_path.exists():
|
|
211
|
+
return global_path
|
|
212
|
+
|
|
213
|
+
# Check package template
|
|
214
|
+
template_path = get_template_path("workflows") / filename
|
|
215
|
+
if template_path.exists():
|
|
216
|
+
return template_path
|
|
217
|
+
|
|
218
|
+
return None
|
ensemble/dashboard.py
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ダッシュボード更新モジュール
|
|
3
|
+
|
|
4
|
+
status/dashboard.md を更新してリアルタイムの進捗を表示する。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from ensemble.lock import atomic_write
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class DashboardUpdater:
|
|
17
|
+
"""
|
|
18
|
+
ダッシュボード更新クラス
|
|
19
|
+
|
|
20
|
+
Markdownファイルを更新してタスクの進捗状況を表示する。
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(self, status_dir: Path | None = None) -> None:
|
|
24
|
+
"""
|
|
25
|
+
ダッシュボードアップデータを初期化する
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
status_dir: ステータスディレクトリ(デフォルト: status/)
|
|
29
|
+
"""
|
|
30
|
+
self.status_dir = status_dir if status_dir else Path("status")
|
|
31
|
+
self.status_dir.mkdir(parents=True, exist_ok=True)
|
|
32
|
+
self.dashboard_path = self.status_dir / "dashboard.md"
|
|
33
|
+
|
|
34
|
+
# 内部状態
|
|
35
|
+
self._phase = "idle"
|
|
36
|
+
self._current_task = ""
|
|
37
|
+
self._completed = 0
|
|
38
|
+
self._total = 0
|
|
39
|
+
self._agents: dict[str, dict[str, str]] = {}
|
|
40
|
+
self._logs: list[str] = []
|
|
41
|
+
|
|
42
|
+
# 初期化時にダッシュボードを作成
|
|
43
|
+
self._write_dashboard()
|
|
44
|
+
|
|
45
|
+
def update_status(
|
|
46
|
+
self,
|
|
47
|
+
phase: str,
|
|
48
|
+
current_task: str,
|
|
49
|
+
agents: dict[str, str] | None = None,
|
|
50
|
+
) -> None:
|
|
51
|
+
"""
|
|
52
|
+
ステータスを更新する
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
phase: 現在のフェーズ
|
|
56
|
+
current_task: 現在のタスク
|
|
57
|
+
agents: エージェントステータス {"name": "status"}
|
|
58
|
+
"""
|
|
59
|
+
self._phase = phase
|
|
60
|
+
self._current_task = current_task
|
|
61
|
+
if agents:
|
|
62
|
+
for name, status in agents.items():
|
|
63
|
+
self._agents[name] = {"status": status, "task": ""}
|
|
64
|
+
self._write_dashboard()
|
|
65
|
+
|
|
66
|
+
def add_log_entry(self, message: str) -> None:
|
|
67
|
+
"""
|
|
68
|
+
ログエントリを追加する
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
message: ログメッセージ
|
|
72
|
+
"""
|
|
73
|
+
timestamp = datetime.now().strftime("%H:%M:%S")
|
|
74
|
+
self._logs.append(f"[{timestamp}] {message}")
|
|
75
|
+
# 最新10件のみ保持
|
|
76
|
+
self._logs = self._logs[-10:]
|
|
77
|
+
self._write_dashboard()
|
|
78
|
+
|
|
79
|
+
def set_phase(self, phase: str) -> None:
|
|
80
|
+
"""
|
|
81
|
+
フェーズを設定する
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
phase: フェーズ名
|
|
85
|
+
"""
|
|
86
|
+
self._phase = phase
|
|
87
|
+
self._write_dashboard()
|
|
88
|
+
|
|
89
|
+
def set_progress(self, completed: int, total: int) -> None:
|
|
90
|
+
"""
|
|
91
|
+
進捗を設定する
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
completed: 完了タスク数
|
|
95
|
+
total: 総タスク数
|
|
96
|
+
"""
|
|
97
|
+
self._completed = completed
|
|
98
|
+
self._total = total
|
|
99
|
+
self._write_dashboard()
|
|
100
|
+
|
|
101
|
+
def set_agent_status(
|
|
102
|
+
self, name: str, status: str, task: str = ""
|
|
103
|
+
) -> None:
|
|
104
|
+
"""
|
|
105
|
+
エージェントのステータスを設定する
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
name: エージェント名
|
|
109
|
+
status: ステータス
|
|
110
|
+
task: 実行中のタスク
|
|
111
|
+
"""
|
|
112
|
+
self._agents[name] = {"status": status, "task": task}
|
|
113
|
+
self._write_dashboard()
|
|
114
|
+
|
|
115
|
+
def clear(self) -> None:
|
|
116
|
+
"""
|
|
117
|
+
ダッシュボードをリセットする
|
|
118
|
+
"""
|
|
119
|
+
self._phase = "idle"
|
|
120
|
+
self._current_task = ""
|
|
121
|
+
self._completed = 0
|
|
122
|
+
self._total = 0
|
|
123
|
+
self._agents = {}
|
|
124
|
+
self._logs = []
|
|
125
|
+
self._write_dashboard()
|
|
126
|
+
|
|
127
|
+
def _write_dashboard(self) -> None:
|
|
128
|
+
"""ダッシュボードファイルを書き込む"""
|
|
129
|
+
now = datetime.now()
|
|
130
|
+
|
|
131
|
+
# エージェントテーブル
|
|
132
|
+
agent_rows = ""
|
|
133
|
+
for name, info in self._agents.items():
|
|
134
|
+
agent_rows += f"| {name} | {info['status']} | {info.get('task', '')} |\n"
|
|
135
|
+
if not agent_rows:
|
|
136
|
+
agent_rows = "| - | - | - |\n"
|
|
137
|
+
|
|
138
|
+
# ログセクション
|
|
139
|
+
log_section = "\n".join(self._logs) if self._logs else "(no logs)"
|
|
140
|
+
|
|
141
|
+
# 進捗表示
|
|
142
|
+
if self._total > 0:
|
|
143
|
+
progress = f"{self._completed}/{self._total}"
|
|
144
|
+
else:
|
|
145
|
+
progress = "-"
|
|
146
|
+
|
|
147
|
+
content = f"""# Ensemble Dashboard
|
|
148
|
+
|
|
149
|
+
**Last Updated**: {now.strftime("%Y-%m-%d %H:%M:%S")}
|
|
150
|
+
|
|
151
|
+
## Status
|
|
152
|
+
|
|
153
|
+
| Phase | Current Task | Progress |
|
|
154
|
+
|-------|--------------|----------|
|
|
155
|
+
| {self._phase} | {self._current_task} | {progress} |
|
|
156
|
+
|
|
157
|
+
## Agents
|
|
158
|
+
|
|
159
|
+
| Agent | Status | Task |
|
|
160
|
+
|-------|--------|------|
|
|
161
|
+
{agent_rows}
|
|
162
|
+
## Recent Logs
|
|
163
|
+
|
|
164
|
+
```
|
|
165
|
+
{log_section}
|
|
166
|
+
```
|
|
167
|
+
"""
|
|
168
|
+
atomic_write(str(self.dashboard_path), content)
|
ensemble/helpers.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""
|
|
2
|
+
src/ensemble/helpers.py - ヘルパー関数
|
|
3
|
+
|
|
4
|
+
Ensemble固有のヘルパー関数を提供する。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import re
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def sanitize_task_id(task_id: str) -> str:
|
|
12
|
+
"""
|
|
13
|
+
タスクIDをサニタイズする
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
task_id: サニタイズするタスクID
|
|
17
|
+
|
|
18
|
+
Returns:
|
|
19
|
+
サニタイズされたタスクID
|
|
20
|
+
"""
|
|
21
|
+
# 英数字、ハイフン、アンダースコアのみ許可
|
|
22
|
+
return re.sub(r"[^a-zA-Z0-9\-_]", "_", task_id)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def get_queue_path(queue_type: str, base_dir: str = ".") -> Path:
|
|
26
|
+
"""
|
|
27
|
+
キューディレクトリのパスを取得する
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
queue_type: キューの種類(tasks, reports, ack)
|
|
31
|
+
base_dir: ベースディレクトリ
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
キューディレクトリのパス
|
|
35
|
+
"""
|
|
36
|
+
valid_types = {"tasks", "reports", "ack", "conductor"}
|
|
37
|
+
if queue_type not in valid_types:
|
|
38
|
+
raise ValueError(f"Invalid queue type: {queue_type}")
|
|
39
|
+
return Path(base_dir) / "queue" / queue_type
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def parse_worker_id(pane_name: str) -> int | None:
|
|
43
|
+
"""
|
|
44
|
+
ペイン名からワーカーIDを抽出する
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
pane_name: ペイン名(例: "worker-1", "worker-2")
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
ワーカーID。抽出できない場合はNone
|
|
51
|
+
"""
|
|
52
|
+
match = re.match(r"worker-(\d+)", pane_name)
|
|
53
|
+
if match:
|
|
54
|
+
return int(match.group(1))
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def format_duration(seconds: float) -> str:
|
|
59
|
+
"""
|
|
60
|
+
秒数を人間が読みやすい形式に変換する
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
seconds: 秒数
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
"1h 23m 45s" 形式の文字列
|
|
67
|
+
"""
|
|
68
|
+
hours, remainder = divmod(int(seconds), 3600)
|
|
69
|
+
minutes, secs = divmod(remainder, 60)
|
|
70
|
+
|
|
71
|
+
parts = []
|
|
72
|
+
if hours > 0:
|
|
73
|
+
parts.append(f"{hours}h")
|
|
74
|
+
if minutes > 0:
|
|
75
|
+
parts.append(f"{minutes}m")
|
|
76
|
+
if secs > 0 or not parts:
|
|
77
|
+
parts.append(f"{secs}s")
|
|
78
|
+
|
|
79
|
+
return " ".join(parts)
|
ensemble/lock.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""
|
|
2
|
+
アトミックロック機構
|
|
3
|
+
|
|
4
|
+
ファイルベースのアトミック操作を提供する。
|
|
5
|
+
mvコマンド(os.rename)のアトミック性を利用。
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import tempfile
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def atomic_write(filepath: str, content: str) -> bool:
|
|
15
|
+
"""
|
|
16
|
+
アトミックな書き込みを行う
|
|
17
|
+
|
|
18
|
+
tmp作成 → mv(アトミック)で安全に書き込む。
|
|
19
|
+
同一ファイルシステム上でのos.renameはアトミック。
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
filepath: 書き込み先ファイルパス
|
|
23
|
+
content: 書き込む内容
|
|
24
|
+
|
|
25
|
+
Returns:
|
|
26
|
+
成功時True、失敗時False
|
|
27
|
+
"""
|
|
28
|
+
dir_path = os.path.dirname(filepath)
|
|
29
|
+
|
|
30
|
+
# 親ディレクトリが存在しない場合は失敗
|
|
31
|
+
if dir_path and not os.path.exists(dir_path):
|
|
32
|
+
return False
|
|
33
|
+
|
|
34
|
+
try:
|
|
35
|
+
# tmpファイルを同じディレクトリに作成(同一ファイルシステム保証)
|
|
36
|
+
fd, tmp_path = tempfile.mkstemp(dir=dir_path if dir_path else ".")
|
|
37
|
+
try:
|
|
38
|
+
os.write(fd, content.encode("utf-8"))
|
|
39
|
+
os.close(fd)
|
|
40
|
+
os.rename(tmp_path, filepath) # アトミック
|
|
41
|
+
return True
|
|
42
|
+
except Exception:
|
|
43
|
+
# 失敗時はtmpファイルを削除
|
|
44
|
+
try:
|
|
45
|
+
os.unlink(tmp_path)
|
|
46
|
+
except OSError:
|
|
47
|
+
pass
|
|
48
|
+
return False
|
|
49
|
+
except Exception:
|
|
50
|
+
return False
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def atomic_claim(filepath: str, processing_dir: str) -> str | None:
|
|
54
|
+
"""
|
|
55
|
+
アトミックなタスク取得を行う
|
|
56
|
+
|
|
57
|
+
mv(アトミック)で処理中ディレクトリへ移動することで、
|
|
58
|
+
複数プロセス間での排他制御を実現。
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
filepath: 取得対象のファイルパス
|
|
62
|
+
processing_dir: 処理中ファイルの移動先ディレクトリ
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
成功時は移動先パス、失敗時(別プロセスが先に取得)はNone
|
|
66
|
+
"""
|
|
67
|
+
filename = os.path.basename(filepath)
|
|
68
|
+
dest = os.path.join(processing_dir, filename)
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
os.rename(filepath, dest) # アトミック
|
|
72
|
+
return dest
|
|
73
|
+
except FileNotFoundError:
|
|
74
|
+
# 別プロセスが先に取得した場合
|
|
75
|
+
return None
|
|
76
|
+
except Exception:
|
|
77
|
+
return None
|
ensemble/logger.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Ensembleログ出力モジュール
|
|
3
|
+
|
|
4
|
+
コンソール: テキスト形式(人間が読みやすい)
|
|
5
|
+
ファイル: JSON形式(機械可読、分析容易)
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from datetime import datetime
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class EnsembleLogger:
|
|
17
|
+
"""
|
|
18
|
+
Ensemble用ログ出力クラス
|
|
19
|
+
|
|
20
|
+
コンソール: テキスト形式(人間が読みやすい)
|
|
21
|
+
ファイル: JSON形式(機械可読、分析容易)
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(self, name: str = "ensemble", log_dir: Path | None = None) -> None:
|
|
25
|
+
"""
|
|
26
|
+
ロガーを初期化する
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
name: ロガー名
|
|
30
|
+
log_dir: ログ出力ディレクトリ(デフォルト: logs/)
|
|
31
|
+
"""
|
|
32
|
+
self.name = name
|
|
33
|
+
self.log_dir = log_dir if log_dir else Path("logs")
|
|
34
|
+
self.log_dir.mkdir(exist_ok=True)
|
|
35
|
+
|
|
36
|
+
def _get_log_file(self) -> Path:
|
|
37
|
+
"""今日のログファイルパスを取得"""
|
|
38
|
+
today = datetime.now().strftime("%Y%m%d")
|
|
39
|
+
return self.log_dir / f"ensemble-{today}.log"
|
|
40
|
+
|
|
41
|
+
def log(self, level: str, message: str, **kwargs: Any) -> None:
|
|
42
|
+
"""
|
|
43
|
+
構造化ログを出力する
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
level: ログレベル (DEBUG, INFO, WARNING, ERROR)
|
|
47
|
+
message: ログメッセージ
|
|
48
|
+
**kwargs: 追加の構造化データ
|
|
49
|
+
"""
|
|
50
|
+
now = datetime.now()
|
|
51
|
+
|
|
52
|
+
# コンソール: テキスト形式
|
|
53
|
+
time_str = now.strftime("%H:%M:%S")
|
|
54
|
+
print(f"{time_str} [{level}] {message}")
|
|
55
|
+
|
|
56
|
+
# ファイル: JSON形式
|
|
57
|
+
log_entry = {
|
|
58
|
+
"timestamp": now.isoformat(),
|
|
59
|
+
"level": level,
|
|
60
|
+
"message": message,
|
|
61
|
+
**kwargs,
|
|
62
|
+
}
|
|
63
|
+
with open(self._get_log_file(), "a") as f:
|
|
64
|
+
f.write(json.dumps(log_entry) + "\n")
|
|
65
|
+
|
|
66
|
+
def debug(self, message: str, **kwargs: Any) -> None:
|
|
67
|
+
"""DEBUGレベルでログ出力"""
|
|
68
|
+
self.log("DEBUG", message, **kwargs)
|
|
69
|
+
|
|
70
|
+
def info(self, message: str, **kwargs: Any) -> None:
|
|
71
|
+
"""INFOレベルでログ出力"""
|
|
72
|
+
self.log("INFO", message, **kwargs)
|
|
73
|
+
|
|
74
|
+
def warning(self, message: str, **kwargs: Any) -> None:
|
|
75
|
+
"""WARNINGレベルでログ出力"""
|
|
76
|
+
self.log("WARNING", message, **kwargs)
|
|
77
|
+
|
|
78
|
+
def error(self, message: str, **kwargs: Any) -> None:
|
|
79
|
+
"""ERRORレベルでログ出力"""
|
|
80
|
+
self.log("ERROR", message, **kwargs)
|