codee-agent 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.
- codee/.gitignore +2 -0
- codee/__init__.py +0 -0
- codee/admin.py +1672 -0
- codee/admin_api.py +61 -0
- codee/admin_cli.py +86 -0
- codee/admin_service.py +1005 -0
- codee/executor.py +381 -0
- codee/init_cli.py +82 -0
- codee/lib/__init__.py +0 -0
- codee/lib/cron_describe.py +33 -0
- codee/lib/runs_db.py +195 -0
- codee/lib/test_runs_db.py +199 -0
- codee/lib/test_trigger_cron_skills.py +485 -0
- codee/lib/test_trigger_issue_skills.py +93 -0
- codee/lib/trigger_aws_sqs_skills.py +224 -0
- codee/lib/trigger_cron_skills.py +364 -0
- codee/lib/trigger_email_skills.py +225 -0
- codee/lib/trigger_issue_skills.py +107 -0
- codee/mail_server.py +45 -0
- codee/start_cli.py +98 -0
- codee/templates/AGENTS.md +42 -0
- codee/templates/CLAUDE.md +1 -0
- codee/templates/skills/aws-sqs-alarm-response/SKILL.md +23 -0
- codee/templates/skills/cron-research-5xx-errors/SKILL.md +17 -0
- codee/templates/skills/story-code-reviewer/SKILL.md +29 -0
- codee/templates/skills/story-developer/SKILL.md +26 -0
- codee/templates/skills/story-planner/SKILL.md +35 -0
- codee/templates/skills/story-planner/assets/readme-template.md +43 -0
- codee/templates/skills/story-qa/SKILL.md +28 -0
- codee/templates/skills/task-developer/SKILL.md +25 -0
- codee/templates/skills/task-qa/SKILL.md +26 -0
- codee/test_admin_api.py +64 -0
- codee/test_admin_cli.py +63 -0
- codee/test_admin_service.py +897 -0
- codee/test_executor.py +190 -0
- codee/test_init_cli.py +131 -0
- codee/test_memory_index.py +31 -0
- codee/test_start_cli.py +164 -0
- codee/workflow_graph.py +83 -0
- codee_admin/__init__.py +1 -0
- codee_admin/codee_admin.py +4 -0
- codee_agent-0.1.0.dist-info/METADATA +66 -0
- codee_agent-0.1.0.dist-info/RECORD +69 -0
- codee_agent-0.1.0.dist-info/WHEEL +4 -0
- codee_agent-0.1.0.dist-info/entry_points.txt +6 -0
- codee_agent-0.1.0.dist-info/licenses/LICENSE +21 -0
- codee_agent_abstract/__init__.py +0 -0
- codee_agent_abstract/provider.py +56 -0
- codee_agent_claude_code/__init__.py +0 -0
- codee_agent_claude_code/provider.py +90 -0
- codee_agent_github_copilot/__init__.py +0 -0
- codee_agent_github_copilot/provider.py +253 -0
- codee_agent_github_copilot/test.py +176 -0
- codee_database/__init__.py +0 -0
- codee_database/database.py +13 -0
- codee_database/oauth_tokens.py +148 -0
- codee_main_context/__init__.py +0 -0
- codee_main_context/context.py +127 -0
- codee_main_context/logging.py +111 -0
- codee_main_context/test_logging.py +90 -0
- codee_tasks_abstract/__init__.py +0 -0
- codee_tasks_abstract/provider.py +58 -0
- codee_tasks_azure_devops/__init__.py +0 -0
- codee_tasks_azure_devops/oauth.py +346 -0
- codee_tasks_azure_devops/provider.py +207 -0
- codee_tasks_azure_devops/test.py +462 -0
- codee_tasks_jira/__init__.py +0 -0
- codee_tasks_jira/provider.py +162 -0
- codee_tasks_jira/test.py +75 -0
codee/executor.py
ADDED
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import subprocess
|
|
3
|
+
import threading
|
|
4
|
+
import time
|
|
5
|
+
import traceback
|
|
6
|
+
import uuid
|
|
7
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
|
|
10
|
+
from codee_agent_abstract.provider import AbstractCodingAgent
|
|
11
|
+
from codee_agent_claude_code.provider import ClaudeCodeAgent
|
|
12
|
+
from codee_agent_github_copilot.provider import GitHubCopilotAgent
|
|
13
|
+
from codee_main_context.context import (
|
|
14
|
+
CodeeMainContext, CodingAgent, Settings, TasksProvider, data_dir,
|
|
15
|
+
load_settings, project_root)
|
|
16
|
+
from codee_main_context.logging import configure_logging, get_logger
|
|
17
|
+
from codee_tasks_abstract.provider import AbstractTasksProvider
|
|
18
|
+
from codee_tasks_azure_devops.provider import AzureDevOpsTasksProvider
|
|
19
|
+
from codee_tasks_jira.provider import JiraTasksProvider
|
|
20
|
+
|
|
21
|
+
from codee.lib import runs_db
|
|
22
|
+
from codee.lib.trigger_aws_sqs_skills import trigger_aws_sqs_skills
|
|
23
|
+
from codee.lib.trigger_cron_skills import trigger_cron_skills
|
|
24
|
+
from codee.lib.trigger_email_skills import trigger_email_skills
|
|
25
|
+
from codee.lib.trigger_issue_skills import (
|
|
26
|
+
find_issue_triggered_skills, issue_statuses, match_issue_skill)
|
|
27
|
+
|
|
28
|
+
log = get_logger(__name__)
|
|
29
|
+
|
|
30
|
+
context = CodeeMainContext(data_dir=data_dir())
|
|
31
|
+
context.settings = load_settings(context.data_dir)
|
|
32
|
+
|
|
33
|
+
# Concrete tasks providers, keyed by the provider selected in settings. Each
|
|
34
|
+
# provider initializes itself from the settings, so nothing here is provider-specific.
|
|
35
|
+
_TASKS_PROVIDERS: dict[TasksProvider, type[AbstractTasksProvider]] = {
|
|
36
|
+
TasksProvider.JIRA: JiraTasksProvider,
|
|
37
|
+
TasksProvider.AZURE_DEVOPS: AzureDevOpsTasksProvider,
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
# Concrete coding agents, keyed by the agent selected in settings. Each agent
|
|
41
|
+
# initializes itself from the settings, so nothing here is agent-specific.
|
|
42
|
+
_CODING_AGENTS: dict[CodingAgent, type[AbstractCodingAgent]] = {
|
|
43
|
+
CodingAgent.CLAUDE_CODE: ClaudeCodeAgent,
|
|
44
|
+
CodingAgent.GITHUB_COPILOT: GitHubCopilotAgent,
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
POLL_INTERVAL = 60 # 1 minute
|
|
48
|
+
|
|
49
|
+
SESSIONS_FILE = context.data_dir / "sessions.json"
|
|
50
|
+
# The project Codee operates on — same root the trigger modules scan for
|
|
51
|
+
# `.claude/skills`. The coding agent is spawned with this as its cwd, so the
|
|
52
|
+
# `/<slug>` messages we build from those skills actually resolve.
|
|
53
|
+
REPO_ROOT = project_root()
|
|
54
|
+
|
|
55
|
+
# Task agents run concurrently in a bounded pool so one long agent (up to 2h)
|
|
56
|
+
# doesn't block the others. Cap comes from the "Max parallel tasks" admin setting.
|
|
57
|
+
MAX_PARALLEL_AGENTS = max(1, context.settings.max_parallel_agents)
|
|
58
|
+
_agent_pool = ThreadPoolExecutor(
|
|
59
|
+
max_workers=MAX_PARALLEL_AGENTS, thread_name_prefix="task-agent"
|
|
60
|
+
)
|
|
61
|
+
# task_ids a worker currently owns (running OR queued). Claimed on the main
|
|
62
|
+
# thread at submit, released by the worker — so the next poll never launches a
|
|
63
|
+
# second agent for a task that's still in an "In Progress"/"CR Needed" state.
|
|
64
|
+
_inflight: set[str] = set()
|
|
65
|
+
_inflight_lock = threading.Lock()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _load_sessions() -> dict[str, str]:
|
|
69
|
+
"""Load task_id -> session_id mapping from disk."""
|
|
70
|
+
if SESSIONS_FILE.exists():
|
|
71
|
+
try:
|
|
72
|
+
return json.loads(SESSIONS_FILE.read_text())
|
|
73
|
+
except (json.JSONDecodeError, OSError):
|
|
74
|
+
return {}
|
|
75
|
+
return {}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _save_sessions(sessions: dict[str, str]) -> None:
|
|
79
|
+
"""Persist task_id -> session_id mapping to disk."""
|
|
80
|
+
SESSIONS_FILE.write_text(json.dumps(sessions, indent=2))
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _get_or_create_session(sessions: dict[str, str], task_id: str) -> str:
|
|
84
|
+
"""Get existing session ID for a task or create a new one."""
|
|
85
|
+
if task_id not in sessions:
|
|
86
|
+
sessions[task_id] = str(uuid.uuid4())
|
|
87
|
+
_save_sessions(sessions)
|
|
88
|
+
return sessions[task_id]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _build_tasks_provider(settings: Settings) -> AbstractTasksProvider:
|
|
92
|
+
provider = _TASKS_PROVIDERS.get(settings.tasks_provider)
|
|
93
|
+
if provider is None:
|
|
94
|
+
raise ValueError(
|
|
95
|
+
f"unsupported tasks provider: {settings.tasks_provider.value}")
|
|
96
|
+
return provider(settings)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _build_coding_agent(settings: Settings) -> AbstractCodingAgent:
|
|
100
|
+
agent = _CODING_AGENTS.get(settings.coding_agent)
|
|
101
|
+
if agent is None:
|
|
102
|
+
raise ValueError(
|
|
103
|
+
f"unsupported coding agent: {settings.coding_agent.value}")
|
|
104
|
+
return agent(settings, REPO_ROOT)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
tasks_provider: AbstractTasksProvider = _build_tasks_provider(context.settings)
|
|
108
|
+
|
|
109
|
+
coding_agent: AbstractCodingAgent = _build_coding_agent(context.settings)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _refresh_config() -> None:
|
|
113
|
+
"""Re-read settings.json and rebuild whatever it changed.
|
|
114
|
+
|
|
115
|
+
Providers capture their credentials at construction, so without this a
|
|
116
|
+
settings edit (new Azure DevOps app, rotated JIRA token, switched provider)
|
|
117
|
+
only took effect after restarting the executor. Rebuilds are conditional so
|
|
118
|
+
a poll that changes nothing keeps the live provider — and with it the
|
|
119
|
+
Azure DevOps refresh lock — untouched.
|
|
120
|
+
"""
|
|
121
|
+
global tasks_provider, coding_agent
|
|
122
|
+
|
|
123
|
+
settings = load_settings(context.data_dir)
|
|
124
|
+
previous = context.settings
|
|
125
|
+
context.settings = settings
|
|
126
|
+
log.debug("re-read settings from %s: provider=%s agent=%s",
|
|
127
|
+
context.data_dir, settings.tasks_provider.value,
|
|
128
|
+
settings.coding_agent.value)
|
|
129
|
+
|
|
130
|
+
if (settings.tasks_provider != previous.tasks_provider
|
|
131
|
+
or settings.credentials != previous.credentials):
|
|
132
|
+
try:
|
|
133
|
+
tasks_provider = _build_tasks_provider(settings)
|
|
134
|
+
except Exception as exc:
|
|
135
|
+
# Keep polling with the provider we have; the next edit gets another go.
|
|
136
|
+
log.error("Failed to apply new tasks provider settings: %s", exc)
|
|
137
|
+
else:
|
|
138
|
+
log.info("Reloaded tasks provider: %s", tasks_provider.describe())
|
|
139
|
+
|
|
140
|
+
if settings.coding_agent != previous.coding_agent:
|
|
141
|
+
try:
|
|
142
|
+
coding_agent = _build_coding_agent(settings)
|
|
143
|
+
except Exception as exc:
|
|
144
|
+
log.error("Failed to apply new coding agent settings: %s", exc)
|
|
145
|
+
else:
|
|
146
|
+
log.info("Reloaded coding agent: %s", settings.coding_agent.value)
|
|
147
|
+
|
|
148
|
+
if settings.max_parallel_agents != previous.max_parallel_agents:
|
|
149
|
+
# The pool is sized once and may have work in flight, so this one still
|
|
150
|
+
# needs a restart rather than being silently ignored.
|
|
151
|
+
log.warning("Max parallel tasks changed to %s; restart the executor to "
|
|
152
|
+
"apply (still running with %s).",
|
|
153
|
+
settings.max_parallel_agents, MAX_PARALLEL_AGENTS)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _current_branch() -> str | None:
|
|
157
|
+
"""Return the current git branch name, or None if it can't be determined."""
|
|
158
|
+
try:
|
|
159
|
+
result = subprocess.run(
|
|
160
|
+
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
|
161
|
+
capture_output=True,
|
|
162
|
+
text=True,
|
|
163
|
+
timeout=30,
|
|
164
|
+
cwd=REPO_ROOT,
|
|
165
|
+
)
|
|
166
|
+
except Exception as exc:
|
|
167
|
+
log.warning("failed to determine current branch: %s", exc)
|
|
168
|
+
return None
|
|
169
|
+
|
|
170
|
+
if result.returncode != 0:
|
|
171
|
+
return None
|
|
172
|
+
return result.stdout.strip() or None
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _pull_latest_code() -> bool:
|
|
176
|
+
"""Update the local repo before polling for tasks.
|
|
177
|
+
|
|
178
|
+
Only pulls when the current branch is the mainline (master or main); on any
|
|
179
|
+
other branch it's a no-op so we don't disturb in-progress work.
|
|
180
|
+
"""
|
|
181
|
+
branch = _current_branch()
|
|
182
|
+
if branch not in ("master", "main"):
|
|
183
|
+
log.debug("not on mainline branch (on '%s'), skipping git pull", branch)
|
|
184
|
+
return True
|
|
185
|
+
|
|
186
|
+
try:
|
|
187
|
+
result = subprocess.run(
|
|
188
|
+
["git", "pull", "origin", branch],
|
|
189
|
+
capture_output=True,
|
|
190
|
+
text=True,
|
|
191
|
+
timeout=300,
|
|
192
|
+
cwd=REPO_ROOT,
|
|
193
|
+
)
|
|
194
|
+
except subprocess.TimeoutExpired:
|
|
195
|
+
log.error("git pull timed out after 5 minutes")
|
|
196
|
+
return False
|
|
197
|
+
except Exception as exc:
|
|
198
|
+
log.error("git pull failed: %s", exc)
|
|
199
|
+
return False
|
|
200
|
+
|
|
201
|
+
if result.returncode != 0:
|
|
202
|
+
error_output = result.stderr.strip() or result.stdout.strip() or "unknown git error"
|
|
203
|
+
log.error("git pull failed: %s", error_output)
|
|
204
|
+
return False
|
|
205
|
+
|
|
206
|
+
output = result.stdout.strip()
|
|
207
|
+
if output:
|
|
208
|
+
log.debug("git pull output: %s", output)
|
|
209
|
+
else:
|
|
210
|
+
log.debug("git pull completed")
|
|
211
|
+
return True
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _run_agent(user_message: str, session_id: str, model: str = "") -> str:
|
|
215
|
+
"""Run the configured coding agent and return its response text.
|
|
216
|
+
|
|
217
|
+
``model`` comes from the triggering skill's ``model:`` frontmatter; agents
|
|
218
|
+
that can't be told which model to use ignore it. Wraps the agent run in job
|
|
219
|
+
tracking; the agent itself raises on any failure so callers can retry.
|
|
220
|
+
"""
|
|
221
|
+
job_id = runs_db.start_job(session_id, user_message, main_context=context)
|
|
222
|
+
log.debug("job %s started: session=%s message=%r model=%r",
|
|
223
|
+
job_id, session_id, user_message, model)
|
|
224
|
+
try:
|
|
225
|
+
return coding_agent.run(user_message, session_id, model)
|
|
226
|
+
finally:
|
|
227
|
+
log.debug("job %s finished", job_id)
|
|
228
|
+
runs_db.finish_job(job_id, main_context=context)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _run_task(task_id: str, message: str, session_id: str, skill_name: str,
|
|
232
|
+
model: str = "") -> None:
|
|
233
|
+
"""Pool worker: run one task's coding agent, then release its in-flight slot.
|
|
234
|
+
|
|
235
|
+
Logs the outcome to the runs table like the cron/email/sqs triggers do, so
|
|
236
|
+
issue-triggered coding runs show up on the dashboard too. Stamped with the
|
|
237
|
+
launch time (not the finish time) so the hourly chart buckets it where it
|
|
238
|
+
actually started — an agent can run for hours.
|
|
239
|
+
"""
|
|
240
|
+
started_at = datetime.now(timezone.utc).isoformat()
|
|
241
|
+
try:
|
|
242
|
+
response = _run_agent(message, session_id, model)
|
|
243
|
+
log.info("Agent response for %s (%d chars): %s",
|
|
244
|
+
task_id, len(response), response)
|
|
245
|
+
runs_db.record_run(skill_name, "issue", session_id, "succeeded",
|
|
246
|
+
started_at=started_at, message=message,
|
|
247
|
+
main_context=context)
|
|
248
|
+
except Exception as exc:
|
|
249
|
+
# Over-limit / transient failure: leave the task in its current
|
|
250
|
+
# status so the next poll retries it.
|
|
251
|
+
log.warning("Failed to run %s, will retry next poll: %s", task_id, exc)
|
|
252
|
+
log.debug("%s failed with:\n%s", task_id, traceback.format_exc())
|
|
253
|
+
runs_db.record_run(skill_name, "issue", session_id, "failed",
|
|
254
|
+
error=str(exc)[:500], started_at=started_at,
|
|
255
|
+
message=message, main_context=context)
|
|
256
|
+
finally:
|
|
257
|
+
with _inflight_lock:
|
|
258
|
+
_inflight.discard(task_id)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _submit_task(task_id: str, message: str, session_id: str, skill_name: str,
|
|
262
|
+
model: str = "") -> bool:
|
|
263
|
+
"""Hand a task to the agent pool unless one is already in flight for it.
|
|
264
|
+
|
|
265
|
+
Returns True if submitted, False if skipped as a duplicate. Only the main
|
|
266
|
+
(polling) thread adds to _inflight and only workers remove, so claiming the
|
|
267
|
+
slot here is race-free against the next tick.
|
|
268
|
+
"""
|
|
269
|
+
with _inflight_lock:
|
|
270
|
+
if task_id in _inflight:
|
|
271
|
+
log.debug("%s already running; skipping duplicate launch.", task_id)
|
|
272
|
+
return False
|
|
273
|
+
_inflight.add(task_id)
|
|
274
|
+
depth = len(_inflight)
|
|
275
|
+
_agent_pool.submit(_run_task, task_id, message,
|
|
276
|
+
session_id, skill_name, model)
|
|
277
|
+
log.info("Submitted %s to agent pool (%d in flight/queued, max %d).",
|
|
278
|
+
task_id, depth, MAX_PARALLEL_AGENTS)
|
|
279
|
+
return True
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def run_once() -> None:
|
|
283
|
+
"""Single cron tick: reconcile scheduled skills, fetch tasks, and run Claude."""
|
|
284
|
+
log.debug("tick: reconciling scheduled skills and polling for tasks")
|
|
285
|
+
_refresh_config()
|
|
286
|
+
|
|
287
|
+
if not _pull_latest_code():
|
|
288
|
+
log.warning("Failed to pull from the repo, still continuing...")
|
|
289
|
+
|
|
290
|
+
trigger_cron_skills(_run_agent, main_context=context)
|
|
291
|
+
trigger_aws_sqs_skills(_run_agent, main_context=context)
|
|
292
|
+
trigger_email_skills(_run_agent, main_context=context)
|
|
293
|
+
|
|
294
|
+
if not tasks_provider.is_configured():
|
|
295
|
+
log.debug("Tasks provider is not configured; skipping poll.")
|
|
296
|
+
return
|
|
297
|
+
|
|
298
|
+
log.debug("Checking %s for tasks...", tasks_provider.describe())
|
|
299
|
+
|
|
300
|
+
with _inflight_lock:
|
|
301
|
+
running = len(_inflight)
|
|
302
|
+
log.debug("Agent pool: %d/%d in flight/queued.",
|
|
303
|
+
running, MAX_PARALLEL_AGENTS)
|
|
304
|
+
|
|
305
|
+
issue_skills = find_issue_triggered_skills()
|
|
306
|
+
if not issue_skills:
|
|
307
|
+
log.debug("No issue-triggered skills found.")
|
|
308
|
+
return
|
|
309
|
+
log.debug("Issue-triggered skills: %s",
|
|
310
|
+
", ".join(skill.slug for skill in issue_skills))
|
|
311
|
+
|
|
312
|
+
tasks = tasks_provider.get_tasks(issue_statuses(issue_skills))
|
|
313
|
+
if not tasks:
|
|
314
|
+
log.debug("No tasks found.")
|
|
315
|
+
return
|
|
316
|
+
|
|
317
|
+
log.info("Found %d task(s).", len(tasks))
|
|
318
|
+
sessions = _load_sessions()
|
|
319
|
+
|
|
320
|
+
for task in tasks:
|
|
321
|
+
task_id = task.key
|
|
322
|
+
with _inflight_lock:
|
|
323
|
+
if task_id in _inflight:
|
|
324
|
+
continue # a worker already owns it; don't re-fetch or re-launch
|
|
325
|
+
summary = task.summary
|
|
326
|
+
status = task.status
|
|
327
|
+
issue_type = task.issue_type
|
|
328
|
+
priority = task.priority
|
|
329
|
+
|
|
330
|
+
# always create a new session
|
|
331
|
+
session_id = str(uuid.uuid4())
|
|
332
|
+
|
|
333
|
+
log.info("Incoming %s (%s, %s, %s): %s",
|
|
334
|
+
task_id, status, issue_type, priority, summary)
|
|
335
|
+
|
|
336
|
+
# Children of a Codee-owned story are driven by that story's own agent
|
|
337
|
+
# run. What marks a story as Codee-owned is the provider's business.
|
|
338
|
+
if issue_type != "Story" and task.is_parent_codee_story:
|
|
339
|
+
log.debug("Skipping %s: parent %s is a Codee story",
|
|
340
|
+
task_id, task.parent.key)
|
|
341
|
+
continue
|
|
342
|
+
|
|
343
|
+
skill = match_issue_skill(issue_skills, status, issue_type)
|
|
344
|
+
if skill is None:
|
|
345
|
+
log.debug("No issue trigger matches %s (%s, %s); skipping",
|
|
346
|
+
task_id, status, issue_type)
|
|
347
|
+
continue
|
|
348
|
+
message = f"/{skill.slug} {task_id}"
|
|
349
|
+
|
|
350
|
+
log.info("Processing %s (%s, %s): %s session-id=%s",
|
|
351
|
+
task_id, status, issue_type, summary, session_id)
|
|
352
|
+
|
|
353
|
+
_submit_task(task_id, message, session_id, skill.name, skill.model)
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def main() -> None:
|
|
357
|
+
# Entry point: install the handler before anything logs. Level comes from
|
|
358
|
+
# CODEE_DEBUG, which `codee-start --debug` exports for this subprocess.
|
|
359
|
+
configure_logging()
|
|
360
|
+
|
|
361
|
+
if not tasks_provider.is_configured():
|
|
362
|
+
log.warning("tasks provider is not configured; task polling stays "
|
|
363
|
+
"idle until it is set up in Settings (no restart needed)")
|
|
364
|
+
|
|
365
|
+
runs_db.clear_active_jobs(context) # purge rows left by a previous process
|
|
366
|
+
|
|
367
|
+
log.info("Starting the main loop (ticking every %ss)...", POLL_INTERVAL)
|
|
368
|
+
log.info("Tasks provider: %s", tasks_provider.describe())
|
|
369
|
+
log.debug("data dir=%s repo root=%s max parallel agents=%d",
|
|
370
|
+
context.data_dir, REPO_ROOT, MAX_PARALLEL_AGENTS)
|
|
371
|
+
|
|
372
|
+
while True:
|
|
373
|
+
try:
|
|
374
|
+
run_once()
|
|
375
|
+
except Exception as exc:
|
|
376
|
+
log.error("Unhandled error: %s %s", exc, traceback.format_exc())
|
|
377
|
+
time.sleep(POLL_INTERVAL)
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
if __name__ == "__main__":
|
|
381
|
+
main()
|
codee/init_cli.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import shutil
|
|
2
|
+
import sys
|
|
3
|
+
from importlib.resources import as_file, files
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
TEMPLATE_ROOT = files("codee").joinpath("templates")
|
|
8
|
+
TARGETS = {
|
|
9
|
+
"AGENTS.md": Path("AGENTS.md"),
|
|
10
|
+
"CLAUDE.md": Path("CLAUDE.md"),
|
|
11
|
+
"skills": Path(".claude/skills"),
|
|
12
|
+
}
|
|
13
|
+
CONFLICT_PATHS = (Path(".claude"), Path("AGENTS.md"), Path("CLAUDE.md"))
|
|
14
|
+
# Directories the agents write into: cloned repositories, scratch files, and
|
|
15
|
+
# long-term memory.
|
|
16
|
+
WORKING_DIRECTORIES = (Path("repositories"), Path("temp"), Path("memory"))
|
|
17
|
+
# Of those, only the per-checkout state is kept out of git. `memory/` is tracked
|
|
18
|
+
# on purpose: the admin UI commits and pushes memory edits (see AdminService).
|
|
19
|
+
GITIGNORE_ENTRIES = ("/repositories", "/temp")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _ignored_patterns(gitignore: Path) -> set[str]:
|
|
23
|
+
"""Patterns already listed, normalized so `/temp`, `temp/` and `temp` match."""
|
|
24
|
+
if not gitignore.is_file():
|
|
25
|
+
return set()
|
|
26
|
+
|
|
27
|
+
patterns = set()
|
|
28
|
+
for line in gitignore.read_text().splitlines():
|
|
29
|
+
stripped = line.strip()
|
|
30
|
+
if stripped and not stripped.startswith("#"):
|
|
31
|
+
patterns.add(stripped.strip("/"))
|
|
32
|
+
return patterns
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def ensure_working_directories(destination: Path) -> None:
|
|
36
|
+
"""Create the runtime directories and make sure git ignores them."""
|
|
37
|
+
for directory in WORKING_DIRECTORIES:
|
|
38
|
+
(destination / directory).mkdir(parents=True, exist_ok=True)
|
|
39
|
+
|
|
40
|
+
gitignore = destination / ".gitignore"
|
|
41
|
+
ignored = _ignored_patterns(gitignore)
|
|
42
|
+
missing = [entry for entry in GITIGNORE_ENTRIES
|
|
43
|
+
if entry.strip("/") not in ignored]
|
|
44
|
+
if not missing:
|
|
45
|
+
return
|
|
46
|
+
|
|
47
|
+
existing = gitignore.read_text() if gitignore.is_file() else ""
|
|
48
|
+
if existing and not existing.endswith("\n"):
|
|
49
|
+
existing += "\n"
|
|
50
|
+
gitignore.write_text(existing + "".join(f"{entry}\n" for entry in missing))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def main() -> int:
|
|
54
|
+
destination = Path.cwd()
|
|
55
|
+
conflicts = [path for path in CONFLICT_PATHS if (
|
|
56
|
+
destination / path).exists()]
|
|
57
|
+
if conflicts:
|
|
58
|
+
joined = ", ".join(str(path) for path in conflicts)
|
|
59
|
+
answer = input(
|
|
60
|
+
f"Existing paths will be updated ({joined}). Continue? [y/N] ")
|
|
61
|
+
if answer.strip().lower() not in {"y", "yes"}:
|
|
62
|
+
print("codee-init: cancelled")
|
|
63
|
+
return 1
|
|
64
|
+
|
|
65
|
+
with as_file(TEMPLATE_ROOT) as template_root:
|
|
66
|
+
for source_name, target_path in TARGETS.items():
|
|
67
|
+
source = template_root / source_name
|
|
68
|
+
target = destination / target_path
|
|
69
|
+
if source.is_dir():
|
|
70
|
+
shutil.copytree(source, target, dirs_exist_ok=True)
|
|
71
|
+
else:
|
|
72
|
+
shutil.copy2(source, target)
|
|
73
|
+
|
|
74
|
+
ensure_working_directories(destination)
|
|
75
|
+
|
|
76
|
+
print("Created AGENTS.md, CLAUDE.md, .claude/skills, "
|
|
77
|
+
"repositories/, temp/ and memory/")
|
|
78
|
+
return 0
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
if __name__ == "__main__":
|
|
82
|
+
sys.exit(main())
|
codee/lib/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Human-readable description of a 5-field cron expression.
|
|
2
|
+
|
|
3
|
+
ponytail: delegates to cron_descriptor (handles ranges, lists, AM/PM). Returns
|
|
4
|
+
None on anything it can't parse so the UI falls back to the raw expression.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from cron_descriptor import Options, get_description
|
|
8
|
+
|
|
9
|
+
_OPTS = Options()
|
|
10
|
+
_OPTS.use_24hour_time_format = False
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def describe_cron(expr):
|
|
14
|
+
if not expr or len(expr.split()) != 5:
|
|
15
|
+
return None
|
|
16
|
+
try:
|
|
17
|
+
return get_description(expr, _OPTS)
|
|
18
|
+
except Exception:
|
|
19
|
+
return None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
if __name__ == "__main__":
|
|
23
|
+
cases = {
|
|
24
|
+
"0 5 * * 2-6": "At 05:00 AM, Tuesday through Saturday",
|
|
25
|
+
"*/5 * * * *": "Every 5 minutes",
|
|
26
|
+
"30 9 * * 1": "At 09:30 AM, only on Monday",
|
|
27
|
+
"5,10 * * * *": "At 5 and 10 minutes past the hour",
|
|
28
|
+
"bad": None,
|
|
29
|
+
}
|
|
30
|
+
for expr, want in cases.items():
|
|
31
|
+
got = describe_cron(expr)
|
|
32
|
+
assert got == want, f"{expr!r}: got {got!r}, want {want!r}"
|
|
33
|
+
print("ok")
|
codee/lib/runs_db.py
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""SQLite-backed log of trigger-skill runs (one row per launched Claude session).
|
|
2
|
+
|
|
3
|
+
Fail-safe by design: recording a run must never break the trigger that called it
|
|
4
|
+
(FR-009), and reading never raises on an empty/missing DB (FR-006).
|
|
5
|
+
"""
|
|
6
|
+
from datetime import datetime, timedelta, timezone
|
|
7
|
+
|
|
8
|
+
from codee_main_context.context import CodeeMainContext
|
|
9
|
+
|
|
10
|
+
from codee_database.database import get_db_connection
|
|
11
|
+
|
|
12
|
+
_COLUMNS = ("id", "skill_name", "trigger_type", "session_id", "status", "error",
|
|
13
|
+
"started_at", "message")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def init(main_context: CodeeMainContext) -> None:
|
|
17
|
+
"""Create the runs table + index if absent, and migrate in the message column. Idempotent."""
|
|
18
|
+
with get_db_connection(main_context) as conn:
|
|
19
|
+
conn.execute(
|
|
20
|
+
"""CREATE TABLE IF NOT EXISTS runs (
|
|
21
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
22
|
+
skill_name TEXT NOT NULL,
|
|
23
|
+
trigger_type TEXT NOT NULL,
|
|
24
|
+
session_id TEXT NOT NULL,
|
|
25
|
+
status TEXT NOT NULL,
|
|
26
|
+
error TEXT,
|
|
27
|
+
started_at TEXT NOT NULL,
|
|
28
|
+
message TEXT
|
|
29
|
+
)"""
|
|
30
|
+
)
|
|
31
|
+
conn.execute(
|
|
32
|
+
"CREATE INDEX IF NOT EXISTS idx_runs_started_at ON runs(started_at DESC)")
|
|
33
|
+
# Migrate pre-feature DBs lacking the message column (added 003-runs-dashboard).
|
|
34
|
+
cols = {row[1] for row in conn.execute("PRAGMA table_info(runs)")}
|
|
35
|
+
if "message" not in cols:
|
|
36
|
+
conn.execute("ALTER TABLE runs ADD COLUMN message TEXT")
|
|
37
|
+
# In-flight claude runs; a row lives only while its subprocess is running.
|
|
38
|
+
conn.execute(
|
|
39
|
+
"""CREATE TABLE IF NOT EXISTS active_jobs (
|
|
40
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
41
|
+
session_id TEXT NOT NULL,
|
|
42
|
+
message TEXT,
|
|
43
|
+
started_at TEXT NOT NULL
|
|
44
|
+
)"""
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def record_run(skill_name, trigger_type, session_id, status, error=None, started_at=None,
|
|
49
|
+
message=None, *, main_context: CodeeMainContext) -> None:
|
|
50
|
+
"""Insert one run row. Never raises to the caller (FR-009)."""
|
|
51
|
+
try:
|
|
52
|
+
init(main_context)
|
|
53
|
+
if started_at is None:
|
|
54
|
+
started_at = datetime.now(timezone.utc).isoformat()
|
|
55
|
+
with get_db_connection(main_context) as conn:
|
|
56
|
+
conn.execute(
|
|
57
|
+
"INSERT INTO runs (skill_name, trigger_type, session_id, status, error,"
|
|
58
|
+
" started_at, message) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
|
59
|
+
(skill_name, trigger_type, session_id,
|
|
60
|
+
status, error, started_at, message),
|
|
61
|
+
)
|
|
62
|
+
except Exception as exc: # ponytail: a logging miss must never abort the skill run
|
|
63
|
+
print(f"[runs_db] Failed to record run for {skill_name}: {exc}")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def recent_runs(limit: int = 100, offset: int = 0, *,
|
|
67
|
+
main_context: CodeeMainContext) -> list[dict]:
|
|
68
|
+
"""Most recent runs newest-first as dicts, skipping `offset` rows; [] on empty/missing DB (FR-006)."""
|
|
69
|
+
try:
|
|
70
|
+
init(main_context)
|
|
71
|
+
with get_db_connection(main_context) as conn:
|
|
72
|
+
rows = conn.execute(
|
|
73
|
+
"SELECT id, skill_name, trigger_type, session_id, status, error,"
|
|
74
|
+
" started_at, message"
|
|
75
|
+
" FROM runs ORDER BY started_at DESC, id DESC LIMIT ? OFFSET ?",
|
|
76
|
+
(limit, max(offset, 0)),
|
|
77
|
+
).fetchall()
|
|
78
|
+
return [dict(zip(_COLUMNS, row)) for row in rows]
|
|
79
|
+
except Exception as exc:
|
|
80
|
+
print(f"[runs_db] Failed to read recent runs: {exc}")
|
|
81
|
+
return []
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def counts(main_context: CodeeMainContext) -> dict:
|
|
85
|
+
"""Total runs and runs in the trailing 24h (UTC). Zeros on empty/missing DB; never raises."""
|
|
86
|
+
try:
|
|
87
|
+
init(main_context)
|
|
88
|
+
cutoff = (datetime.now(timezone.utc) - timedelta(hours=24)).isoformat()
|
|
89
|
+
with get_db_connection(main_context) as conn:
|
|
90
|
+
total = conn.execute("SELECT COUNT(*) FROM runs").fetchone()[0]
|
|
91
|
+
last_24h = conn.execute(
|
|
92
|
+
"SELECT COUNT(*) FROM runs WHERE started_at > ?", (cutoff,)
|
|
93
|
+
).fetchone()[0]
|
|
94
|
+
return {"total": total, "last_24h": last_24h}
|
|
95
|
+
except Exception as exc:
|
|
96
|
+
print(f"[runs_db] Failed to read counts: {exc}")
|
|
97
|
+
return {"total": 0, "last_24h": 0}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def clear_active_jobs(main_context: CodeeMainContext) -> None:
|
|
101
|
+
"""Drop all in-flight job rows. Called on cron_jira startup to purge stale rows."""
|
|
102
|
+
try:
|
|
103
|
+
init(main_context)
|
|
104
|
+
with get_db_connection(main_context) as conn:
|
|
105
|
+
conn.execute("DELETE FROM active_jobs")
|
|
106
|
+
except Exception as exc: # ponytail: never abort startup over a bookkeeping wipe
|
|
107
|
+
print(f"[runs_db] Failed to clear active jobs: {exc}")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def start_job(session_id, message, started_at=None, *,
|
|
111
|
+
main_context: CodeeMainContext) -> int | None:
|
|
112
|
+
"""Mark a claude run as in-flight. Returns its job id (or None if logging failed)."""
|
|
113
|
+
try:
|
|
114
|
+
init(main_context)
|
|
115
|
+
if started_at is None:
|
|
116
|
+
started_at = datetime.now(timezone.utc).isoformat()
|
|
117
|
+
with get_db_connection(main_context) as conn:
|
|
118
|
+
cur = conn.execute(
|
|
119
|
+
"INSERT INTO active_jobs (session_id, message, started_at) VALUES (?, ?, ?)",
|
|
120
|
+
(session_id, message, started_at),
|
|
121
|
+
)
|
|
122
|
+
return cur.lastrowid
|
|
123
|
+
except Exception as exc: # ponytail: a logging miss must never abort the run
|
|
124
|
+
print(f"[runs_db] Failed to start job {session_id}: {exc}")
|
|
125
|
+
return None
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def finish_job(job_id, main_context: CodeeMainContext) -> None:
|
|
129
|
+
"""Remove an in-flight job row once its subprocess returns. No-op on None."""
|
|
130
|
+
if job_id is None:
|
|
131
|
+
return
|
|
132
|
+
try:
|
|
133
|
+
with get_db_connection(main_context) as conn:
|
|
134
|
+
conn.execute("DELETE FROM active_jobs WHERE id = ?", (job_id,))
|
|
135
|
+
except Exception as exc:
|
|
136
|
+
print(f"[runs_db] Failed to finish job {job_id}: {exc}")
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def active_jobs(main_context: CodeeMainContext) -> list[dict]:
|
|
140
|
+
"""In-flight jobs, youngest-first, each with elapsed seconds. [] on empty/missing DB."""
|
|
141
|
+
try:
|
|
142
|
+
init(main_context)
|
|
143
|
+
with get_db_connection(main_context) as conn:
|
|
144
|
+
rows = conn.execute(
|
|
145
|
+
"SELECT id, session_id, message, started_at FROM active_jobs"
|
|
146
|
+
).fetchall()
|
|
147
|
+
except Exception as exc:
|
|
148
|
+
print(f"[runs_db] Failed to read active jobs: {exc}")
|
|
149
|
+
return []
|
|
150
|
+
now = datetime.now(timezone.utc)
|
|
151
|
+
jobs = []
|
|
152
|
+
for job_id, session_id, message, started_at in rows:
|
|
153
|
+
try:
|
|
154
|
+
elapsed = int(
|
|
155
|
+
(now - datetime.fromisoformat(started_at)).total_seconds())
|
|
156
|
+
except (ValueError, TypeError):
|
|
157
|
+
elapsed = 0 # ponytail: bad timestamp -> show 0, don't drop the row
|
|
158
|
+
jobs.append({"id": job_id, "session_id": session_id, "message": message,
|
|
159
|
+
"started_at": started_at, "elapsed": max(elapsed, 0)})
|
|
160
|
+
jobs.sort(key=lambda j: j["elapsed"])
|
|
161
|
+
return jobs
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def fmt_elapsed(secs: int) -> str:
|
|
165
|
+
h, rem = divmod(secs, 3600)
|
|
166
|
+
m, s = divmod(rem, 60)
|
|
167
|
+
return f"{h}h {m}m" if h else f"{m}m {s}s" if m else f"{s}s"
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def runs_by_hour(main_context: CodeeMainContext) -> list[dict]:
|
|
171
|
+
"""Run counts bucketed by hour over the trailing 24h (UTC), oldest-first.
|
|
172
|
+
|
|
173
|
+
Always returns 24 buckets (so empty hours show as 0). Never raises.
|
|
174
|
+
"""
|
|
175
|
+
now = datetime.now(timezone.utc).replace(minute=0, second=0, microsecond=0)
|
|
176
|
+
buckets = [now - timedelta(hours=h) for h in range(23, -1, -1)]
|
|
177
|
+
counts = {b: 0 for b in buckets}
|
|
178
|
+
try:
|
|
179
|
+
init(main_context)
|
|
180
|
+
with get_db_connection(main_context) as conn:
|
|
181
|
+
rows = conn.execute(
|
|
182
|
+
"SELECT started_at FROM runs WHERE started_at >= ?", (buckets[0].isoformat(
|
|
183
|
+
),)
|
|
184
|
+
).fetchall()
|
|
185
|
+
for (ts,) in rows:
|
|
186
|
+
try:
|
|
187
|
+
hour = datetime.fromisoformat(ts).astimezone(timezone.utc).replace(
|
|
188
|
+
minute=0, second=0, microsecond=0)
|
|
189
|
+
except (ValueError, TypeError):
|
|
190
|
+
continue # ponytail: skip a malformed timestamp, don't drop the whole chart
|
|
191
|
+
if hour in counts:
|
|
192
|
+
counts[hour] += 1
|
|
193
|
+
except Exception as exc:
|
|
194
|
+
print(f"[runs_db] Failed to bucket runs by hour: {exc}")
|
|
195
|
+
return [{"hour": b.strftime("%H:00"), "runs": counts[b]} for b in buckets]
|