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
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import tempfile
|
|
2
|
+
import unittest
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from codee.lib.trigger_issue_skills import (
|
|
6
|
+
find_issue_triggered_skills,
|
|
7
|
+
issue_statuses,
|
|
8
|
+
match_issue_skill,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class IssueTriggeredSkillsTest(unittest.TestCase):
|
|
13
|
+
def _skill(self, root: Path, slug: str, frontmatter: str) -> None:
|
|
14
|
+
directory = root / slug
|
|
15
|
+
directory.mkdir()
|
|
16
|
+
(directory / "SKILL.md").write_text(f"---\n{frontmatter}---\nBody\n")
|
|
17
|
+
|
|
18
|
+
def test_reads_the_model_frontmatter_when_present(self) -> None:
|
|
19
|
+
with tempfile.TemporaryDirectory() as temporary_directory:
|
|
20
|
+
root = Path(temporary_directory)
|
|
21
|
+
self._skill(
|
|
22
|
+
root, "with-model",
|
|
23
|
+
"name: With model\ndisable-model-invocation: true\n"
|
|
24
|
+
"x-codee-trigger: issue\nx-codee-issue-status: [Ready]\n"
|
|
25
|
+
"x-codee-issue-type: story\nmodel: claude-opus-5\n",
|
|
26
|
+
)
|
|
27
|
+
self._skill(
|
|
28
|
+
root, "without-model",
|
|
29
|
+
"name: Without model\ndisable-model-invocation: true\n"
|
|
30
|
+
"x-codee-trigger: issue\nx-codee-issue-status: [Ready]\n"
|
|
31
|
+
"x-codee-issue-type: task\n",
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
models = {skill.slug: skill.model
|
|
35
|
+
for skill in find_issue_triggered_skills(root)}
|
|
36
|
+
|
|
37
|
+
self.assertEqual(models,
|
|
38
|
+
{"with-model": "claude-opus-5", "without-model": ""})
|
|
39
|
+
|
|
40
|
+
def test_loads_valid_issue_type_and_rejects_invalid_skills(self) -> None:
|
|
41
|
+
with tempfile.TemporaryDirectory() as temporary_directory:
|
|
42
|
+
root = Path(temporary_directory)
|
|
43
|
+
self._skill(
|
|
44
|
+
root,
|
|
45
|
+
"valid",
|
|
46
|
+
"name: Valid\ndisable-model-invocation: true\n"
|
|
47
|
+
"x-codee-trigger: issue\n"
|
|
48
|
+
"x-codee-issue-status: [Ready, Custom status]\n"
|
|
49
|
+
"x-codee-issue-type: story\n",
|
|
50
|
+
)
|
|
51
|
+
self._skill(
|
|
52
|
+
root,
|
|
53
|
+
"invalid",
|
|
54
|
+
"name: Invalid\nx-codee-trigger: issue\n"
|
|
55
|
+
"x-codee-issue-status: [Ready]\n",
|
|
56
|
+
)
|
|
57
|
+
self._skill(
|
|
58
|
+
root,
|
|
59
|
+
"invalid-type",
|
|
60
|
+
"name: Invalid type\ndisable-model-invocation: true\n"
|
|
61
|
+
"x-codee-trigger: issue\nx-codee-issue-status: [Ready]\n"
|
|
62
|
+
"x-codee-issue-type: bug\n",
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
skills = find_issue_triggered_skills(root)
|
|
66
|
+
|
|
67
|
+
self.assertEqual([skill.slug for skill in skills], ["valid"])
|
|
68
|
+
self.assertEqual(issue_statuses(skills), [
|
|
69
|
+
"Ready", "Custom status"])
|
|
70
|
+
self.assertEqual(match_issue_skill(
|
|
71
|
+
skills, "custom STATUS", "Story").slug, "valid")
|
|
72
|
+
|
|
73
|
+
def test_matches_only_the_requested_issue_type(self) -> None:
|
|
74
|
+
with tempfile.TemporaryDirectory() as temporary_directory:
|
|
75
|
+
root = Path(temporary_directory)
|
|
76
|
+
common = (
|
|
77
|
+
"disable-model-invocation: true\nx-codee-trigger: issue\n"
|
|
78
|
+
"x-codee-issue-status: ['In Progress']\n"
|
|
79
|
+
)
|
|
80
|
+
self._skill(root, "story", f"{common}x-codee-issue-type: story\n")
|
|
81
|
+
self._skill(root, "task", f"{common}x-codee-issue-type: task\n")
|
|
82
|
+
skills = find_issue_triggered_skills(root)
|
|
83
|
+
|
|
84
|
+
self.assertEqual(match_issue_skill(
|
|
85
|
+
skills, "in progress", "Story").slug, "story")
|
|
86
|
+
self.assertEqual(match_issue_skill(
|
|
87
|
+
skills, "in progress", "Task").slug, "task")
|
|
88
|
+
self.assertIsNone(match_issue_skill(
|
|
89
|
+
skills, "in progress", "Bug"))
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
if __name__ == "__main__":
|
|
93
|
+
unittest.main()
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import uuid
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Callable, Protocol
|
|
5
|
+
|
|
6
|
+
from codee_main_context.context import CodeeMainContext
|
|
7
|
+
|
|
8
|
+
from codee.lib import runs_db
|
|
9
|
+
from codee_main_context.context import project_root, skills_dir as default_skills_dir
|
|
10
|
+
|
|
11
|
+
REPO_ROOT = project_root()
|
|
12
|
+
SKILLS_DIR = default_skills_dir(REPO_ROOT)
|
|
13
|
+
|
|
14
|
+
RunClaude = Callable[[str, str, str], str]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class AwsSqsTriggeredSkill:
|
|
19
|
+
key: str
|
|
20
|
+
name: str
|
|
21
|
+
path: Path
|
|
22
|
+
queue: str
|
|
23
|
+
body: str
|
|
24
|
+
model: str
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class AwsSqsMessage:
|
|
29
|
+
content: str
|
|
30
|
+
queue_url: str
|
|
31
|
+
receipt_handle: str
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class AwsSqsMessageSource(Protocol):
|
|
35
|
+
def receive(self, skill: AwsSqsTriggeredSkill) -> AwsSqsMessage | None:
|
|
36
|
+
pass
|
|
37
|
+
|
|
38
|
+
def delete(self, message: AwsSqsMessage) -> None:
|
|
39
|
+
pass
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def trigger_aws_sqs_skills(
|
|
43
|
+
run_claude: RunClaude,
|
|
44
|
+
*,
|
|
45
|
+
skills_dir: Path = SKILLS_DIR,
|
|
46
|
+
sqs_message_source: AwsSqsMessageSource | None = None,
|
|
47
|
+
main_context: CodeeMainContext
|
|
48
|
+
) -> None:
|
|
49
|
+
"""Scan skills with AWS SQS trigger frontmatter and run one message per skill."""
|
|
50
|
+
skills = find_aws_sqs_triggered_skills(skills_dir)
|
|
51
|
+
if not skills:
|
|
52
|
+
print("[aws_sqs_skills] No AWS SQS-triggered skills found.")
|
|
53
|
+
return
|
|
54
|
+
|
|
55
|
+
if sqs_message_source is None:
|
|
56
|
+
sqs_message_source = Boto3AwsSqsMessageSource()
|
|
57
|
+
|
|
58
|
+
for skill in skills:
|
|
59
|
+
try:
|
|
60
|
+
message = sqs_message_source.receive(skill)
|
|
61
|
+
except Exception as exc:
|
|
62
|
+
print(
|
|
63
|
+
f"[aws_sqs_skills] Failed to poll SQS trigger for {skill.name}: {exc}")
|
|
64
|
+
continue
|
|
65
|
+
|
|
66
|
+
if message is None:
|
|
67
|
+
continue
|
|
68
|
+
|
|
69
|
+
print(
|
|
70
|
+
f"[aws_sqs_skills] Running {skill.name} from SQS queue {skill.queue}")
|
|
71
|
+
session_id = str(uuid.uuid4())
|
|
72
|
+
prompt = render_aws_sqs_prompt(skill.body, message.content)
|
|
73
|
+
try:
|
|
74
|
+
response = run_claude(prompt, session_id, skill.model)
|
|
75
|
+
print(
|
|
76
|
+
f"[aws_sqs_skills] Claude response for {skill.name} ({len(response)} chars)")
|
|
77
|
+
sqs_message_source.delete(message)
|
|
78
|
+
runs_db.record_run(skill.name, "aws-sqs",
|
|
79
|
+
session_id, "succeeded", message=prompt,
|
|
80
|
+
main_context=main_context)
|
|
81
|
+
except Exception as exc:
|
|
82
|
+
print(f"[aws_sqs_skills] Failed to run {skill.name}: {exc}")
|
|
83
|
+
runs_db.record_run(skill.name, "aws-sqs", session_id, "failed",
|
|
84
|
+
error=str(exc)[:500], message=prompt,
|
|
85
|
+
main_context=main_context)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def find_aws_sqs_triggered_skills(skills_dir: Path = SKILLS_DIR) -> list[AwsSqsTriggeredSkill]:
|
|
89
|
+
skills: list[AwsSqsTriggeredSkill] = []
|
|
90
|
+
for path in sorted(skills_dir.glob("*/SKILL.md")):
|
|
91
|
+
try:
|
|
92
|
+
metadata, body = _parse_skill_file(path.read_text())
|
|
93
|
+
except OSError as exc:
|
|
94
|
+
print(f"[aws_sqs_skills] Failed to read {path}: {exc}")
|
|
95
|
+
continue
|
|
96
|
+
|
|
97
|
+
trigger = metadata.get("x-codee-trigger", "").strip().lower()
|
|
98
|
+
if not trigger:
|
|
99
|
+
continue
|
|
100
|
+
|
|
101
|
+
name = metadata.get(
|
|
102
|
+
"name", path.parent.name).strip() or path.parent.name
|
|
103
|
+
if metadata.get("disable-model-invocation", "").strip().lower() != "true":
|
|
104
|
+
print(
|
|
105
|
+
f"[aws_sqs_skills] ERROR: {path} declares x-codee-trigger but is missing "
|
|
106
|
+
"disable-model-invocation: true; skipping."
|
|
107
|
+
)
|
|
108
|
+
continue
|
|
109
|
+
|
|
110
|
+
if trigger != "aws-sqs":
|
|
111
|
+
continue
|
|
112
|
+
|
|
113
|
+
queue = metadata.get("x-codee-aws-sqs-queue", "").strip()
|
|
114
|
+
if not queue:
|
|
115
|
+
print(
|
|
116
|
+
f"[aws_sqs_skills] ERROR: {path} declares x-codee-trigger: aws-sqs but is missing "
|
|
117
|
+
"x-codee-aws-sqs-queue; skipping."
|
|
118
|
+
)
|
|
119
|
+
continue
|
|
120
|
+
|
|
121
|
+
skills.append(
|
|
122
|
+
AwsSqsTriggeredSkill(
|
|
123
|
+
key=_skill_key(path),
|
|
124
|
+
name=name,
|
|
125
|
+
path=path,
|
|
126
|
+
queue=queue,
|
|
127
|
+
body=body.strip(),
|
|
128
|
+
model=metadata.get("model", "").strip(),
|
|
129
|
+
)
|
|
130
|
+
)
|
|
131
|
+
return skills
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class Boto3AwsSqsMessageSource:
|
|
135
|
+
def __init__(self, *, visibility_timeout: int = 7200) -> None:
|
|
136
|
+
import boto3
|
|
137
|
+
|
|
138
|
+
self._sqs = boto3.client("sqs")
|
|
139
|
+
self._visibility_timeout = visibility_timeout
|
|
140
|
+
self._queue_urls_by_skill: dict[str, str] = {}
|
|
141
|
+
|
|
142
|
+
def receive(self, skill: AwsSqsTriggeredSkill) -> AwsSqsMessage | None:
|
|
143
|
+
queue_url = self._resolve_queue_url(skill)
|
|
144
|
+
print("Receiving from SQS queue:", queue_url)
|
|
145
|
+
response = self._sqs.receive_message(
|
|
146
|
+
QueueUrl=queue_url,
|
|
147
|
+
MaxNumberOfMessages=1,
|
|
148
|
+
WaitTimeSeconds=0,
|
|
149
|
+
VisibilityTimeout=self._visibility_timeout,
|
|
150
|
+
AttributeNames=["All"],
|
|
151
|
+
MessageAttributeNames=["All"],
|
|
152
|
+
)
|
|
153
|
+
messages = response.get("Messages", [])
|
|
154
|
+
if not messages:
|
|
155
|
+
return None
|
|
156
|
+
|
|
157
|
+
raw_message = messages[0]
|
|
158
|
+
return AwsSqsMessage(
|
|
159
|
+
content=raw_message.get("Body", ""),
|
|
160
|
+
queue_url=queue_url,
|
|
161
|
+
receipt_handle=raw_message["ReceiptHandle"],
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
def delete(self, message: AwsSqsMessage) -> None:
|
|
165
|
+
self._sqs.delete_message(
|
|
166
|
+
QueueUrl=message.queue_url, ReceiptHandle=message.receipt_handle)
|
|
167
|
+
|
|
168
|
+
def _resolve_queue_url(self, skill: AwsSqsTriggeredSkill) -> str:
|
|
169
|
+
if skill.key in self._queue_urls_by_skill:
|
|
170
|
+
return self._queue_urls_by_skill[skill.key]
|
|
171
|
+
|
|
172
|
+
if skill.queue.startswith("http://") or skill.queue.startswith("https://"):
|
|
173
|
+
queue_url = skill.queue
|
|
174
|
+
else:
|
|
175
|
+
queue_url = self._sqs.get_queue_url(
|
|
176
|
+
QueueName=skill.queue)["QueueUrl"]
|
|
177
|
+
|
|
178
|
+
self._queue_urls_by_skill[skill.key] = queue_url
|
|
179
|
+
return queue_url
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def render_aws_sqs_prompt(body: str, content: str) -> str:
|
|
183
|
+
if "{CONTENT}" in body:
|
|
184
|
+
return body.replace("{CONTENT}", content)
|
|
185
|
+
return f"{body.rstrip()}\n\n{content}"
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _parse_skill_file(contents: str) -> tuple[dict[str, str], str]:
|
|
189
|
+
lines = contents.splitlines()
|
|
190
|
+
if not lines or lines[0].strip() != "---":
|
|
191
|
+
return {}, contents
|
|
192
|
+
|
|
193
|
+
end_index = None
|
|
194
|
+
for index, line in enumerate(lines[1:], start=1):
|
|
195
|
+
if line.strip() == "---":
|
|
196
|
+
end_index = index
|
|
197
|
+
break
|
|
198
|
+
|
|
199
|
+
if end_index is None:
|
|
200
|
+
return {}, contents
|
|
201
|
+
|
|
202
|
+
metadata: dict[str, str] = {}
|
|
203
|
+
for line in lines[1:end_index]:
|
|
204
|
+
stripped = line.strip()
|
|
205
|
+
if not stripped or stripped.startswith("#") or ":" not in stripped:
|
|
206
|
+
continue
|
|
207
|
+
key, _, value = stripped.partition(":")
|
|
208
|
+
metadata[key.strip().lower()] = _strip_quotes(value.strip())
|
|
209
|
+
|
|
210
|
+
body = "\n".join(lines[end_index + 1:]).lstrip("\n")
|
|
211
|
+
return metadata, body
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _skill_key(path: Path) -> str:
|
|
215
|
+
try:
|
|
216
|
+
return str(path.relative_to(REPO_ROOT))
|
|
217
|
+
except ValueError:
|
|
218
|
+
return str(path)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _strip_quotes(value: str) -> str:
|
|
222
|
+
if len(value) >= 2 and value[0] == value[-1] and value[0] in {'"', "'"}:
|
|
223
|
+
return value[1:-1]
|
|
224
|
+
return value
|
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import uuid
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from datetime import datetime, timedelta
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Callable
|
|
7
|
+
|
|
8
|
+
from codee_main_context.context import CodeeMainContext
|
|
9
|
+
|
|
10
|
+
from codee.lib import runs_db
|
|
11
|
+
from codee_main_context.context import project_root, skills_dir as default_skills_dir
|
|
12
|
+
|
|
13
|
+
REPO_ROOT = project_root()
|
|
14
|
+
SKILLS_DIR = default_skills_dir(REPO_ROOT)
|
|
15
|
+
|
|
16
|
+
# How far back to look for a missed scheduled fire. A tick can be delayed for
|
|
17
|
+
# hours because a single run_once may block on long-running Claude jobs (each up
|
|
18
|
+
# to 2h). Without catch-up the exact cron minute is skipped and the job waits a
|
|
19
|
+
# whole period (e.g. a week for "0 0 * * 1"). 24h covers realistic backlogs
|
|
20
|
+
# while still refusing to run occurrences that are absurdly stale.
|
|
21
|
+
DEFAULT_CATCHUP = timedelta(hours=24)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# (prompt, session_id, model) -> agent reply. `model` is the skill's `model:`
|
|
25
|
+
# frontmatter, empty when it declares none.
|
|
26
|
+
RunClaude = Callable[[str, str, str], str]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class CronField:
|
|
31
|
+
values: set[int]
|
|
32
|
+
is_wildcard: bool
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class ScheduledSkill:
|
|
37
|
+
key: str
|
|
38
|
+
name: str
|
|
39
|
+
path: Path
|
|
40
|
+
cron: str
|
|
41
|
+
body: str
|
|
42
|
+
model: str
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _get_force_file_path(main_context: CodeeMainContext) -> Path:
|
|
46
|
+
return main_context.data_dir / "cron_skill_force.json"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def trigger_cron_skills(
|
|
50
|
+
run_claude: RunClaude,
|
|
51
|
+
*,
|
|
52
|
+
now: datetime | None = None,
|
|
53
|
+
skills_dir: Path = SKILLS_DIR,
|
|
54
|
+
state_file: Path = None,
|
|
55
|
+
force_file: Path = None,
|
|
56
|
+
catchup_window: timedelta = DEFAULT_CATCHUP,
|
|
57
|
+
main_context: CodeeMainContext
|
|
58
|
+
) -> None:
|
|
59
|
+
"""Scan skills with cron frontmatter and run due jobs.
|
|
60
|
+
|
|
61
|
+
A job is considered due if its most recent scheduled fire at or before
|
|
62
|
+
``now`` (within ``catchup_window``) has not been recorded yet. This lets a
|
|
63
|
+
delayed tick still run a job whose exact cron minute was missed while the
|
|
64
|
+
process was busy, instead of skipping it until the next period.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
if state_file is None:
|
|
68
|
+
state_file = main_context.data_dir / "cron_skill_runs.json"
|
|
69
|
+
if force_file is None:
|
|
70
|
+
force_file = _get_force_file_path(main_context)
|
|
71
|
+
|
|
72
|
+
tick = (now or datetime.now()).replace(second=0, microsecond=0)
|
|
73
|
+
state = _load_state(state_file)
|
|
74
|
+
forced = _load_force(force_file)
|
|
75
|
+
changed = False
|
|
76
|
+
force_changed = False
|
|
77
|
+
|
|
78
|
+
scheduled_skills = _find_scheduled_skills(skills_dir)
|
|
79
|
+
if not scheduled_skills:
|
|
80
|
+
print("[cron_skills] No scheduled skills found.")
|
|
81
|
+
return
|
|
82
|
+
|
|
83
|
+
for skill in scheduled_skills:
|
|
84
|
+
is_forced = skill.key in forced
|
|
85
|
+
|
|
86
|
+
if is_forced:
|
|
87
|
+
# Admin UI asked for a one-off run on this tick; ignore the schedule.
|
|
88
|
+
print(
|
|
89
|
+
f"[cron_skills] Force-running {skill.name} on this tick (manual schedule).")
|
|
90
|
+
else:
|
|
91
|
+
try:
|
|
92
|
+
due = _latest_due(skill.cron, tick, catchup_window)
|
|
93
|
+
except ValueError as exc:
|
|
94
|
+
print(f"[cron_skills] Invalid cron for {skill.name}: {exc}")
|
|
95
|
+
continue
|
|
96
|
+
|
|
97
|
+
if due is None:
|
|
98
|
+
continue
|
|
99
|
+
|
|
100
|
+
due_slot = due.isoformat(timespec="minutes")
|
|
101
|
+
if state.get(skill.key) == due_slot:
|
|
102
|
+
continue
|
|
103
|
+
|
|
104
|
+
if skill.key not in state and due != tick:
|
|
105
|
+
# First time we observe this skill and the only due fire is in the
|
|
106
|
+
# past (before the process/skill existed). Don't back-run it; seed a
|
|
107
|
+
# baseline so genuine future misses are still caught up.
|
|
108
|
+
state[skill.key] = due_slot
|
|
109
|
+
changed = True
|
|
110
|
+
continue
|
|
111
|
+
|
|
112
|
+
if due != tick:
|
|
113
|
+
print(
|
|
114
|
+
f"[cron_skills] {skill.name} ({skill.cron}) scheduled at {due_slot} "
|
|
115
|
+
f"was missed; catching up at {tick.isoformat(timespec='minutes')}."
|
|
116
|
+
)
|
|
117
|
+
print(
|
|
118
|
+
f"[cron_skills] Running {skill.name} ({skill.cron}) from {skill.path}")
|
|
119
|
+
session_id = str(uuid.uuid4())
|
|
120
|
+
try:
|
|
121
|
+
response = run_claude(skill.body, session_id, skill.model)
|
|
122
|
+
print(
|
|
123
|
+
f"[cron_skills] Claude response for {skill.name} ({len(response)} chars)")
|
|
124
|
+
runs_db.record_run(skill.name, "cron", session_id,
|
|
125
|
+
"succeeded", message=skill.body,
|
|
126
|
+
main_context=main_context)
|
|
127
|
+
except Exception as exc:
|
|
128
|
+
# Don't advance the state slot: leave the job "due" so a later tick
|
|
129
|
+
# (within the catch-up window) retries it instead of skipping the
|
|
130
|
+
# whole period. Over-limit runs raise here and get retried.
|
|
131
|
+
print(
|
|
132
|
+
f"[cron_skills] Failed to run {skill.name}, will retry: {exc}")
|
|
133
|
+
runs_db.record_run(skill.name, "cron", session_id, "failed",
|
|
134
|
+
error=str(exc)[:500], message=skill.body,
|
|
135
|
+
main_context=main_context)
|
|
136
|
+
continue
|
|
137
|
+
if is_forced:
|
|
138
|
+
# One-shot off-schedule run: clear the flag but leave the cron slot
|
|
139
|
+
# untracked, so the next tick still catches up a genuinely-due fire.
|
|
140
|
+
forced.discard(skill.key)
|
|
141
|
+
force_changed = True
|
|
142
|
+
else:
|
|
143
|
+
state[skill.key] = due_slot
|
|
144
|
+
changed = True
|
|
145
|
+
|
|
146
|
+
if changed:
|
|
147
|
+
_save_state(state_file, state)
|
|
148
|
+
if force_changed:
|
|
149
|
+
_save_force(force_file, forced)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _latest_due(cron: str, tick: datetime, catchup_window: timedelta) -> datetime | None:
|
|
153
|
+
"""Most recent minute matching ``cron`` in ``[tick - catchup_window, tick]``.
|
|
154
|
+
|
|
155
|
+
Returns ``None`` if no scheduled fire falls within the window. Validates the
|
|
156
|
+
expression on the first candidate so an invalid cron raises ``ValueError``.
|
|
157
|
+
"""
|
|
158
|
+
max_steps = max(0, int(catchup_window.total_seconds() // 60))
|
|
159
|
+
candidate = tick
|
|
160
|
+
for _ in range(max_steps + 1):
|
|
161
|
+
if _cron_matches(cron, candidate):
|
|
162
|
+
return candidate
|
|
163
|
+
candidate -= timedelta(minutes=1)
|
|
164
|
+
return None
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _find_scheduled_skills(skills_dir: Path = SKILLS_DIR) -> list[ScheduledSkill]:
|
|
168
|
+
skills: list[ScheduledSkill] = []
|
|
169
|
+
for path in sorted(skills_dir.glob("*/SKILL.md")):
|
|
170
|
+
try:
|
|
171
|
+
metadata, body = _parse_skill_file(path.read_text())
|
|
172
|
+
except OSError as exc:
|
|
173
|
+
print(f"[cron_skills] Failed to read {path}: {exc}")
|
|
174
|
+
continue
|
|
175
|
+
|
|
176
|
+
# New convention: x-codee-trigger: cron + x-codee-cron. Old `cron` kept as fallback.
|
|
177
|
+
cron = (metadata.get("x-codee-cron")
|
|
178
|
+
or metadata.get("cron", "")).strip()
|
|
179
|
+
if not cron:
|
|
180
|
+
continue
|
|
181
|
+
|
|
182
|
+
name = metadata.get(
|
|
183
|
+
"name", path.parent.name).strip() or path.parent.name
|
|
184
|
+
if metadata.get("disable-model-invocation", "").strip().lower() != "true":
|
|
185
|
+
print(
|
|
186
|
+
f"[cron_skills] ERROR: {path} declares cron but is missing "
|
|
187
|
+
"disable-model-invocation: true; skipping."
|
|
188
|
+
)
|
|
189
|
+
continue
|
|
190
|
+
|
|
191
|
+
skills.append(
|
|
192
|
+
ScheduledSkill(
|
|
193
|
+
key=_skill_key(path),
|
|
194
|
+
name=name,
|
|
195
|
+
path=path,
|
|
196
|
+
cron=cron,
|
|
197
|
+
body=body.strip(),
|
|
198
|
+
model=metadata.get("model", "").strip(),
|
|
199
|
+
)
|
|
200
|
+
)
|
|
201
|
+
return skills
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _parse_skill_file(contents: str) -> tuple[dict[str, str], str]:
|
|
205
|
+
lines = contents.splitlines()
|
|
206
|
+
if not lines or lines[0].strip() != "---":
|
|
207
|
+
return {}, contents
|
|
208
|
+
|
|
209
|
+
end_index = None
|
|
210
|
+
for index, line in enumerate(lines[1:], start=1):
|
|
211
|
+
if line.strip() == "---":
|
|
212
|
+
end_index = index
|
|
213
|
+
break
|
|
214
|
+
|
|
215
|
+
if end_index is None:
|
|
216
|
+
return {}, contents
|
|
217
|
+
|
|
218
|
+
metadata: dict[str, str] = {}
|
|
219
|
+
for line in lines[1:end_index]:
|
|
220
|
+
stripped = line.strip()
|
|
221
|
+
if not stripped or stripped.startswith("#") or ":" not in stripped:
|
|
222
|
+
continue
|
|
223
|
+
key, _, value = stripped.partition(":")
|
|
224
|
+
metadata[key.strip().lower()] = _strip_quotes(value.strip())
|
|
225
|
+
|
|
226
|
+
body = "\n".join(lines[end_index + 1:]).lstrip("\n")
|
|
227
|
+
return metadata, body
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _cron_matches(expression: str, tick: datetime) -> bool:
|
|
231
|
+
parts = expression.split()
|
|
232
|
+
if len(parts) != 5:
|
|
233
|
+
raise ValueError(
|
|
234
|
+
"expected 5 fields: minute hour day-of-month month day-of-week")
|
|
235
|
+
|
|
236
|
+
minute = _parse_cron_field(parts[0], 0, 59)
|
|
237
|
+
hour = _parse_cron_field(parts[1], 0, 23)
|
|
238
|
+
day_of_month = _parse_cron_field(parts[2], 1, 31)
|
|
239
|
+
month = _parse_cron_field(parts[3], 1, 12)
|
|
240
|
+
day_of_week = _parse_cron_field(
|
|
241
|
+
parts[4], 0, 7, normalize_seven_to_zero=True)
|
|
242
|
+
cron_day_of_week = (tick.weekday() + 1) % 7
|
|
243
|
+
|
|
244
|
+
if tick.minute not in minute.values or tick.hour not in hour.values or tick.month not in month.values:
|
|
245
|
+
return False
|
|
246
|
+
|
|
247
|
+
month_day_matches = tick.day in day_of_month.values
|
|
248
|
+
week_day_matches = cron_day_of_week in day_of_week.values
|
|
249
|
+
|
|
250
|
+
if not day_of_month.is_wildcard and not day_of_week.is_wildcard:
|
|
251
|
+
return month_day_matches or week_day_matches
|
|
252
|
+
return month_day_matches and week_day_matches
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _parse_cron_field(
|
|
256
|
+
field: str,
|
|
257
|
+
minimum: int,
|
|
258
|
+
maximum: int,
|
|
259
|
+
*,
|
|
260
|
+
normalize_seven_to_zero: bool = False,
|
|
261
|
+
) -> CronField:
|
|
262
|
+
values: set[int] = set()
|
|
263
|
+
is_wildcard = True
|
|
264
|
+
|
|
265
|
+
for item in field.split(","):
|
|
266
|
+
item = item.strip()
|
|
267
|
+
if not item:
|
|
268
|
+
raise ValueError(f"empty cron field item in {field!r}")
|
|
269
|
+
|
|
270
|
+
base, slash, step_text = item.partition("/")
|
|
271
|
+
step = 1
|
|
272
|
+
if slash:
|
|
273
|
+
if not step_text.isdigit() or int(step_text) <= 0:
|
|
274
|
+
raise ValueError(f"invalid step {step_text!r} in {field!r}")
|
|
275
|
+
step = int(step_text)
|
|
276
|
+
|
|
277
|
+
if base == "*":
|
|
278
|
+
start = minimum
|
|
279
|
+
end = maximum
|
|
280
|
+
elif "-" in base:
|
|
281
|
+
start_text, _, end_text = base.partition("-")
|
|
282
|
+
start = _parse_cron_int(
|
|
283
|
+
start_text, minimum, maximum, normalize_seven_to_zero)
|
|
284
|
+
end = _parse_cron_int(
|
|
285
|
+
end_text, minimum, maximum, normalize_seven_to_zero)
|
|
286
|
+
is_wildcard = False
|
|
287
|
+
if start > end:
|
|
288
|
+
raise ValueError(f"invalid range {base!r} in {field!r}")
|
|
289
|
+
else:
|
|
290
|
+
value = _parse_cron_int(
|
|
291
|
+
base, minimum, maximum, normalize_seven_to_zero)
|
|
292
|
+
start = value
|
|
293
|
+
end = value
|
|
294
|
+
is_wildcard = False
|
|
295
|
+
|
|
296
|
+
values.update(range(start, end + 1, step))
|
|
297
|
+
|
|
298
|
+
if normalize_seven_to_zero and 7 in values:
|
|
299
|
+
values.remove(7)
|
|
300
|
+
values.add(0)
|
|
301
|
+
|
|
302
|
+
return CronField(values=values, is_wildcard=is_wildcard)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _parse_cron_int(value: str, minimum: int, maximum: int, normalize_seven_to_zero: bool) -> int:
|
|
306
|
+
if not value.isdigit():
|
|
307
|
+
raise ValueError(f"invalid integer {value!r}")
|
|
308
|
+
parsed = int(value)
|
|
309
|
+
if normalize_seven_to_zero and parsed == 7:
|
|
310
|
+
return parsed
|
|
311
|
+
if parsed < minimum or parsed > maximum:
|
|
312
|
+
raise ValueError(f"value {parsed} outside {minimum}-{maximum}")
|
|
313
|
+
return parsed
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _load_state(state_file: Path) -> dict[str, str]:
|
|
317
|
+
try:
|
|
318
|
+
if state_file.exists():
|
|
319
|
+
data = json.loads(state_file.read_text())
|
|
320
|
+
if isinstance(data, dict):
|
|
321
|
+
return {str(key): str(value) for key, value in data.items()}
|
|
322
|
+
except (json.JSONDecodeError, OSError) as exc:
|
|
323
|
+
print(f"[cron_skills] Failed to load state from {state_file}: {exc}")
|
|
324
|
+
return {}
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _save_state(state_file: Path, state: dict[str, str]) -> None:
|
|
328
|
+
state_file.write_text(json.dumps(state, indent=2, sort_keys=True))
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _load_force(force_file: Path) -> set[str]:
|
|
332
|
+
try:
|
|
333
|
+
if force_file.exists():
|
|
334
|
+
data = json.loads(force_file.read_text())
|
|
335
|
+
if isinstance(data, list):
|
|
336
|
+
return {str(key) for key in data}
|
|
337
|
+
except (json.JSONDecodeError, OSError) as exc:
|
|
338
|
+
print(
|
|
339
|
+
f"[cron_skills] Failed to load force list from {force_file}: {exc}")
|
|
340
|
+
return set()
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _save_force(force_file: Path, keys: set[str]) -> None:
|
|
344
|
+
force_file.write_text(json.dumps(sorted(keys), indent=2))
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def request_force_run(skill_path: Path, main_context: CodeeMainContext) -> None:
|
|
348
|
+
"""Queue a cron skill to run on the next trigger tick, ignoring its schedule."""
|
|
349
|
+
keys = _load_force(_get_force_file_path(main_context))
|
|
350
|
+
keys.add(_skill_key(Path(skill_path)))
|
|
351
|
+
_save_force(_get_force_file_path(main_context), keys)
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _skill_key(path: Path) -> str:
|
|
355
|
+
try:
|
|
356
|
+
return str(path.relative_to(REPO_ROOT))
|
|
357
|
+
except ValueError:
|
|
358
|
+
return str(path)
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _strip_quotes(value: str) -> str:
|
|
362
|
+
if len(value) >= 2 and value[0] == value[-1] and value[0] in {'"', "'"}:
|
|
363
|
+
return value[1:-1]
|
|
364
|
+
return value
|