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.
Files changed (69) hide show
  1. codee/.gitignore +2 -0
  2. codee/__init__.py +0 -0
  3. codee/admin.py +1672 -0
  4. codee/admin_api.py +61 -0
  5. codee/admin_cli.py +86 -0
  6. codee/admin_service.py +1005 -0
  7. codee/executor.py +381 -0
  8. codee/init_cli.py +82 -0
  9. codee/lib/__init__.py +0 -0
  10. codee/lib/cron_describe.py +33 -0
  11. codee/lib/runs_db.py +195 -0
  12. codee/lib/test_runs_db.py +199 -0
  13. codee/lib/test_trigger_cron_skills.py +485 -0
  14. codee/lib/test_trigger_issue_skills.py +93 -0
  15. codee/lib/trigger_aws_sqs_skills.py +224 -0
  16. codee/lib/trigger_cron_skills.py +364 -0
  17. codee/lib/trigger_email_skills.py +225 -0
  18. codee/lib/trigger_issue_skills.py +107 -0
  19. codee/mail_server.py +45 -0
  20. codee/start_cli.py +98 -0
  21. codee/templates/AGENTS.md +42 -0
  22. codee/templates/CLAUDE.md +1 -0
  23. codee/templates/skills/aws-sqs-alarm-response/SKILL.md +23 -0
  24. codee/templates/skills/cron-research-5xx-errors/SKILL.md +17 -0
  25. codee/templates/skills/story-code-reviewer/SKILL.md +29 -0
  26. codee/templates/skills/story-developer/SKILL.md +26 -0
  27. codee/templates/skills/story-planner/SKILL.md +35 -0
  28. codee/templates/skills/story-planner/assets/readme-template.md +43 -0
  29. codee/templates/skills/story-qa/SKILL.md +28 -0
  30. codee/templates/skills/task-developer/SKILL.md +25 -0
  31. codee/templates/skills/task-qa/SKILL.md +26 -0
  32. codee/test_admin_api.py +64 -0
  33. codee/test_admin_cli.py +63 -0
  34. codee/test_admin_service.py +897 -0
  35. codee/test_executor.py +190 -0
  36. codee/test_init_cli.py +131 -0
  37. codee/test_memory_index.py +31 -0
  38. codee/test_start_cli.py +164 -0
  39. codee/workflow_graph.py +83 -0
  40. codee_admin/__init__.py +1 -0
  41. codee_admin/codee_admin.py +4 -0
  42. codee_agent-0.1.0.dist-info/METADATA +66 -0
  43. codee_agent-0.1.0.dist-info/RECORD +69 -0
  44. codee_agent-0.1.0.dist-info/WHEEL +4 -0
  45. codee_agent-0.1.0.dist-info/entry_points.txt +6 -0
  46. codee_agent-0.1.0.dist-info/licenses/LICENSE +21 -0
  47. codee_agent_abstract/__init__.py +0 -0
  48. codee_agent_abstract/provider.py +56 -0
  49. codee_agent_claude_code/__init__.py +0 -0
  50. codee_agent_claude_code/provider.py +90 -0
  51. codee_agent_github_copilot/__init__.py +0 -0
  52. codee_agent_github_copilot/provider.py +253 -0
  53. codee_agent_github_copilot/test.py +176 -0
  54. codee_database/__init__.py +0 -0
  55. codee_database/database.py +13 -0
  56. codee_database/oauth_tokens.py +148 -0
  57. codee_main_context/__init__.py +0 -0
  58. codee_main_context/context.py +127 -0
  59. codee_main_context/logging.py +111 -0
  60. codee_main_context/test_logging.py +90 -0
  61. codee_tasks_abstract/__init__.py +0 -0
  62. codee_tasks_abstract/provider.py +58 -0
  63. codee_tasks_azure_devops/__init__.py +0 -0
  64. codee_tasks_azure_devops/oauth.py +346 -0
  65. codee_tasks_azure_devops/provider.py +207 -0
  66. codee_tasks_azure_devops/test.py +462 -0
  67. codee_tasks_jira/__init__.py +0 -0
  68. codee_tasks_jira/provider.py +162 -0
  69. 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]