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/admin_service.py
ADDED
|
@@ -0,0 +1,1005 @@
|
|
|
1
|
+
"""Framework-independent operations for the Codee admin UI."""
|
|
2
|
+
import json
|
|
3
|
+
import re
|
|
4
|
+
import os
|
|
5
|
+
import shutil
|
|
6
|
+
import subprocess
|
|
7
|
+
import threading
|
|
8
|
+
import uuid
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
from urllib.parse import urlparse
|
|
13
|
+
|
|
14
|
+
import yaml
|
|
15
|
+
from dotenv import load_dotenv
|
|
16
|
+
|
|
17
|
+
from codee_database import oauth_tokens
|
|
18
|
+
from codee_tasks_azure_devops import oauth as azure_oauth
|
|
19
|
+
from codee_agent_abstract.provider import AbstractCodingAgent, AgentModel
|
|
20
|
+
from codee_agent_claude_code.provider import ClaudeCodeAgent
|
|
21
|
+
from codee_agent_github_copilot.provider import GitHubCopilotAgent
|
|
22
|
+
from codee.lib import runs_db
|
|
23
|
+
from codee.lib.cron_describe import describe_cron
|
|
24
|
+
from codee.lib.trigger_cron_skills import trigger_cron_skills
|
|
25
|
+
from codee.lib.trigger_issue_skills import (
|
|
26
|
+
ISSUE_TYPES,
|
|
27
|
+
IssueTriggeredSkill,
|
|
28
|
+
find_issue_triggered_skills,
|
|
29
|
+
)
|
|
30
|
+
from codee_main_context.context import (
|
|
31
|
+
CodeeMainContext,
|
|
32
|
+
CodingAgent,
|
|
33
|
+
Settings,
|
|
34
|
+
TasksProvider,
|
|
35
|
+
data_dir,
|
|
36
|
+
load_settings,
|
|
37
|
+
memory_dir,
|
|
38
|
+
project_root,
|
|
39
|
+
save_settings,
|
|
40
|
+
skills_dir,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
load_dotenv()
|
|
44
|
+
|
|
45
|
+
MANAGED = {
|
|
46
|
+
"name",
|
|
47
|
+
"description",
|
|
48
|
+
"model",
|
|
49
|
+
"disable-model-invocation",
|
|
50
|
+
"cron",
|
|
51
|
+
"x-codee-trigger",
|
|
52
|
+
"x-codee-issue-status",
|
|
53
|
+
"x-codee-issue-type",
|
|
54
|
+
"x-codee-cron",
|
|
55
|
+
"x-codee-email-address",
|
|
56
|
+
"x-codee-aws-sqs-queue",
|
|
57
|
+
}
|
|
58
|
+
AGENTS_FILE = "AGENTS.md"
|
|
59
|
+
SKILL_TYPES = [
|
|
60
|
+
"knowledge",
|
|
61
|
+
"slash command",
|
|
62
|
+
"issue trigger",
|
|
63
|
+
"cron trigger",
|
|
64
|
+
"email trigger",
|
|
65
|
+
"aws-sqs trigger",
|
|
66
|
+
]
|
|
67
|
+
FM_RE = re.compile(r"^---\s*\n(.*?)\n---\s*\n?(.*)$", re.DOTALL)
|
|
68
|
+
INDEX_RE = re.compile(
|
|
69
|
+
r"^- \[(?P<title>.+?)\]\((?P<file>[^)]+\.md)\)(?:\s*—\s*(?P<hook>.*))?$")
|
|
70
|
+
|
|
71
|
+
_CODING_AGENTS: dict[CodingAgent, type[AbstractCodingAgent]] = {
|
|
72
|
+
CodingAgent.CLAUDE_CODE: ClaudeCodeAgent,
|
|
73
|
+
CodingAgent.GITHUB_COPILOT: GitHubCopilotAgent,
|
|
74
|
+
}
|
|
75
|
+
WORKFLOW_NODE_SPACING = 440
|
|
76
|
+
WORKFLOW_NODE_CENTER_OFFSET = 110
|
|
77
|
+
|
|
78
|
+
# Port the admin UI listens on unless ``codee-admin --port`` says otherwise.
|
|
79
|
+
# The OAuth redirect URI is built from it, and Entra ID matches redirect URIs
|
|
80
|
+
# exactly — including the port — so both have to agree on one value.
|
|
81
|
+
DEFAULT_ADMIN_PORT = 8501
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def parse_index(text: str) -> list[dict[str, Any]]:
|
|
85
|
+
"""Parse MEMORY.md while preserving non-conforming lines verbatim."""
|
|
86
|
+
entries = []
|
|
87
|
+
for lineno, raw in enumerate(text.splitlines()):
|
|
88
|
+
match = INDEX_RE.match(raw)
|
|
89
|
+
if match:
|
|
90
|
+
entries.append({
|
|
91
|
+
"title": match.group("title"),
|
|
92
|
+
"file": match.group("file"),
|
|
93
|
+
"hook": match.group("hook") or "",
|
|
94
|
+
"lineno": lineno,
|
|
95
|
+
"raw": raw,
|
|
96
|
+
"matched": True,
|
|
97
|
+
})
|
|
98
|
+
elif raw.strip():
|
|
99
|
+
entries.append({
|
|
100
|
+
"title": "",
|
|
101
|
+
"file": "",
|
|
102
|
+
"hook": "",
|
|
103
|
+
"lineno": lineno,
|
|
104
|
+
"raw": raw,
|
|
105
|
+
"matched": False,
|
|
106
|
+
})
|
|
107
|
+
return entries
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def slugify(value: str) -> str:
|
|
111
|
+
return re.sub(r"[^a-z0-9-]+", "-", value.strip().lower()).strip("-")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def parse_skill(text: str) -> tuple[dict[str, Any], str]:
|
|
115
|
+
match = FM_RE.match(text)
|
|
116
|
+
if not match:
|
|
117
|
+
return {}, text
|
|
118
|
+
return (yaml.safe_load(match.group(1)) or {}), match.group(2)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def infer_skill_type(frontmatter: dict[str, Any]) -> str:
|
|
122
|
+
trigger = frontmatter.get("x-codee-trigger")
|
|
123
|
+
if trigger == "issue":
|
|
124
|
+
return "issue trigger"
|
|
125
|
+
if trigger == "aws-sqs":
|
|
126
|
+
return "aws-sqs trigger"
|
|
127
|
+
if trigger == "email":
|
|
128
|
+
return "email trigger"
|
|
129
|
+
if trigger == "cron" or frontmatter.get("x-codee-cron") or frontmatter.get("cron"):
|
|
130
|
+
return "cron trigger"
|
|
131
|
+
if frontmatter.get("disable-model-invocation"):
|
|
132
|
+
return "slash command"
|
|
133
|
+
return "knowledge"
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def dump_frontmatter(frontmatter: dict[str, Any]) -> str:
|
|
137
|
+
return yaml.safe_dump(
|
|
138
|
+
frontmatter,
|
|
139
|
+
sort_keys=False,
|
|
140
|
+
allow_unicode=True,
|
|
141
|
+
default_flow_style=False,
|
|
142
|
+
width=10**9,
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def parse_extra_frontmatter(text: str) -> tuple[dict[str, Any], str]:
|
|
147
|
+
"""Read the free-form frontmatter field, or explain why it cannot be used."""
|
|
148
|
+
if not text.strip():
|
|
149
|
+
return {}, ""
|
|
150
|
+
try:
|
|
151
|
+
parsed = yaml.safe_load(text)
|
|
152
|
+
except yaml.YAMLError as error:
|
|
153
|
+
return {}, f"Other frontmatter fields are not valid YAML: {error}"
|
|
154
|
+
if not isinstance(parsed, dict):
|
|
155
|
+
return {}, "Write other frontmatter fields as `key: value` lines"
|
|
156
|
+
managed = [str(key) for key in parsed if key in MANAGED]
|
|
157
|
+
if managed:
|
|
158
|
+
return {}, (f"{', '.join(managed)} already has a field of its own: "
|
|
159
|
+
"remove it from the other frontmatter fields")
|
|
160
|
+
return {str(key): value for key, value in parsed.items()}, ""
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def build_skill(frontmatter: dict[str, Any], extra: dict[str, Any], body: str) -> str:
|
|
164
|
+
return f"---\n{dump_frontmatter({**frontmatter, **extra})}---\n\n{body.lstrip()}\n"
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _format_issue_status(value: Any) -> str:
|
|
168
|
+
values = value if isinstance(value, list) else [value]
|
|
169
|
+
return ", ".join(str(status) for status in values if status)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _string_values(value: Any) -> list[str]:
|
|
173
|
+
if not isinstance(value, list):
|
|
174
|
+
return []
|
|
175
|
+
return list(dict.fromkeys(
|
|
176
|
+
text for item in value
|
|
177
|
+
if (text := str(item).strip())
|
|
178
|
+
))
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _transition_values(value: Any) -> list[dict[str, str]]:
|
|
182
|
+
if not isinstance(value, list):
|
|
183
|
+
return []
|
|
184
|
+
transitions = []
|
|
185
|
+
for item in value:
|
|
186
|
+
if not isinstance(item, dict):
|
|
187
|
+
continue
|
|
188
|
+
source = str(item.get("source", "")).strip()
|
|
189
|
+
target = str(item.get("target", "")).strip()
|
|
190
|
+
label = str(item.get("label", "")).strip()
|
|
191
|
+
evidence = str(item.get("evidence", "")).strip()
|
|
192
|
+
if source and target:
|
|
193
|
+
transitions.append({
|
|
194
|
+
"source": source,
|
|
195
|
+
"target": target,
|
|
196
|
+
"label": label,
|
|
197
|
+
"evidence": evidence,
|
|
198
|
+
})
|
|
199
|
+
return transitions
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _remove_redundant_skill_transitions(
|
|
203
|
+
transitions: list[dict[str, str]],
|
|
204
|
+
) -> list[dict[str, str]]:
|
|
205
|
+
"""Remove same-skill edges that bypass an inferred multi-step path."""
|
|
206
|
+
retained = []
|
|
207
|
+
for candidate in transitions:
|
|
208
|
+
source = candidate["source"].casefold()
|
|
209
|
+
target = candidate["target"].casefold()
|
|
210
|
+
label = candidate["label"].casefold()
|
|
211
|
+
adjacency: dict[str, set[str]] = {}
|
|
212
|
+
for transition in transitions:
|
|
213
|
+
if transition is candidate or transition["label"].casefold() != label:
|
|
214
|
+
continue
|
|
215
|
+
adjacency.setdefault(transition["source"].casefold(), set()).add(
|
|
216
|
+
transition["target"].casefold()
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
pending = list(adjacency.get(source, ()))
|
|
220
|
+
visited = {source}
|
|
221
|
+
while pending:
|
|
222
|
+
status = pending.pop()
|
|
223
|
+
if status == target:
|
|
224
|
+
break
|
|
225
|
+
if status in visited:
|
|
226
|
+
continue
|
|
227
|
+
visited.add(status)
|
|
228
|
+
pending.extend(adjacency.get(status, ()))
|
|
229
|
+
else:
|
|
230
|
+
retained.append(candidate)
|
|
231
|
+
return retained
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
class AdminService:
|
|
235
|
+
"""Synchronous local operations used by Reflex event handlers."""
|
|
236
|
+
|
|
237
|
+
def __init__(self) -> None:
|
|
238
|
+
self.root = project_root()
|
|
239
|
+
self.skills_dir = skills_dir(self.root)
|
|
240
|
+
self.agents_file = self.root / AGENTS_FILE
|
|
241
|
+
self.memory_dir = memory_dir(self.root)
|
|
242
|
+
self.memory_index = self.memory_dir / "MEMORY.md"
|
|
243
|
+
self.data_dir = data_dir(self.root)
|
|
244
|
+
self.session_viewer = os.environ.get("CODEE_SESSION_VIEWER_URL", "")
|
|
245
|
+
self.skills_dir.mkdir(parents=True, exist_ok=True)
|
|
246
|
+
self.memory_dir.mkdir(parents=True, exist_ok=True)
|
|
247
|
+
self.data_dir.mkdir(parents=True, exist_ok=True)
|
|
248
|
+
self.context = CodeeMainContext(data_dir=self.data_dir)
|
|
249
|
+
self.context.settings = load_settings(self.data_dir)
|
|
250
|
+
self._workflow_cache: dict[str, Any] | None = None
|
|
251
|
+
self._workflow_lock = threading.Lock()
|
|
252
|
+
# Asking an agent for its catalog can mean spawning its CLI, so the
|
|
253
|
+
# answer is cached per agent for the life of the process.
|
|
254
|
+
self._models_cache: dict[CodingAgent, list[AgentModel]] = {}
|
|
255
|
+
self._models_lock = threading.Lock()
|
|
256
|
+
|
|
257
|
+
def _git_push(self, message: str) -> tuple[bool, str]:
|
|
258
|
+
# AGENTS.md is only staged once it exists, so git add never fails on it.
|
|
259
|
+
staged = [str(self.skills_dir), str(self.memory_dir)]
|
|
260
|
+
if self.agents_file.exists():
|
|
261
|
+
staged.append(str(self.agents_file))
|
|
262
|
+
try:
|
|
263
|
+
subprocess.run(
|
|
264
|
+
["git", "-C", str(self.root), "add", "-A", *staged],
|
|
265
|
+
check=True,
|
|
266
|
+
capture_output=True,
|
|
267
|
+
text=True,
|
|
268
|
+
)
|
|
269
|
+
result = subprocess.run(
|
|
270
|
+
["git", "-C", str(self.root), "commit", "-m", message],
|
|
271
|
+
capture_output=True,
|
|
272
|
+
text=True,
|
|
273
|
+
)
|
|
274
|
+
output = result.stdout + result.stderr
|
|
275
|
+
if result.returncode != 0 and "nothing to commit" in output:
|
|
276
|
+
return True, "nothing to commit"
|
|
277
|
+
if result.returncode != 0:
|
|
278
|
+
return False, output
|
|
279
|
+
result = subprocess.run(
|
|
280
|
+
["git", "-C", str(self.root), "push"],
|
|
281
|
+
capture_output=True,
|
|
282
|
+
text=True,
|
|
283
|
+
)
|
|
284
|
+
return result.returncode == 0, result.stdout + result.stderr
|
|
285
|
+
except Exception as error:
|
|
286
|
+
return False, str(error)
|
|
287
|
+
|
|
288
|
+
def _write_and_push(self, path: Path, content: str, message: str) -> tuple[bool, bool, str]:
|
|
289
|
+
"""Write the file, then push. The write succeeds even when the push fails."""
|
|
290
|
+
path.write_text(content)
|
|
291
|
+
pushed, output = self._git_push(message)
|
|
292
|
+
relative_path = path.relative_to(self.root)
|
|
293
|
+
if pushed:
|
|
294
|
+
return True, True, f"Saved and pushed {relative_path}"
|
|
295
|
+
return True, False, f"Saved {relative_path}, but Git push failed: {output}"
|
|
296
|
+
|
|
297
|
+
def list_skills(self) -> list[dict[str, str]]:
|
|
298
|
+
skills = []
|
|
299
|
+
for path in sorted(self.skills_dir.glob("*/SKILL.md")):
|
|
300
|
+
frontmatter, _ = parse_skill(path.read_text())
|
|
301
|
+
skills.append({
|
|
302
|
+
"slug": path.parent.name,
|
|
303
|
+
"name": str(frontmatter.get("name", path.parent.name)),
|
|
304
|
+
"description": str(frontmatter.get("description", "")),
|
|
305
|
+
"type": infer_skill_type(frontmatter),
|
|
306
|
+
"issue_status": _format_issue_status(
|
|
307
|
+
frontmatter.get("x-codee-issue-status", [])
|
|
308
|
+
),
|
|
309
|
+
"issue_type": str(
|
|
310
|
+
frontmatter.get("x-codee-issue-type", "")
|
|
311
|
+
).strip().lower(),
|
|
312
|
+
})
|
|
313
|
+
return skills
|
|
314
|
+
|
|
315
|
+
def list_agent_models(self) -> list[dict[str, str]]:
|
|
316
|
+
"""Models the configured coding agent offers, for the skill editor's picker.
|
|
317
|
+
|
|
318
|
+
Best-effort: an agent that can't be asked yields an empty list and the
|
|
319
|
+
editor falls back to a hand-typed model id.
|
|
320
|
+
"""
|
|
321
|
+
agent_key = self.context.settings.coding_agent
|
|
322
|
+
with self._models_lock:
|
|
323
|
+
models = self._models_cache.get(agent_key)
|
|
324
|
+
if models is None:
|
|
325
|
+
agent_type = _CODING_AGENTS.get(agent_key)
|
|
326
|
+
try:
|
|
327
|
+
models = agent_type.list_models() if agent_type else []
|
|
328
|
+
except Exception as error:
|
|
329
|
+
print(f"[admin] Failed to list models for "
|
|
330
|
+
f"{agent_key.value}: {error}")
|
|
331
|
+
models = []
|
|
332
|
+
self._models_cache[agent_key] = models
|
|
333
|
+
return [{"id": model.id, "name": model.name} for model in models]
|
|
334
|
+
|
|
335
|
+
def resolve_skill_slug(self, label: str) -> str:
|
|
336
|
+
"""Map a workflow transition label back to the skill directory it names."""
|
|
337
|
+
target = label.strip().casefold()
|
|
338
|
+
if not target:
|
|
339
|
+
return ""
|
|
340
|
+
for skill in self.list_skills():
|
|
341
|
+
if target in (skill["slug"].casefold(), skill["name"].casefold()):
|
|
342
|
+
return skill["slug"]
|
|
343
|
+
return ""
|
|
344
|
+
|
|
345
|
+
def generate_workflow(self, force: bool = False) -> dict[str, Any]:
|
|
346
|
+
"""Return cached workflows by issue type, regenerating when requested."""
|
|
347
|
+
workflow_lock = getattr(self, "_workflow_lock", None)
|
|
348
|
+
if workflow_lock is None:
|
|
349
|
+
workflow_lock = self._workflow_lock = threading.Lock()
|
|
350
|
+
with workflow_lock:
|
|
351
|
+
workflow_cache = getattr(self, "_workflow_cache", None)
|
|
352
|
+
if not force and workflow_cache is not None:
|
|
353
|
+
return workflow_cache
|
|
354
|
+
self._workflow_cache = self._generate_workflow()
|
|
355
|
+
return self._workflow_cache
|
|
356
|
+
|
|
357
|
+
def _generate_workflow(self) -> dict[str, Any]:
|
|
358
|
+
"""Infer separate status graphs for story and task skills."""
|
|
359
|
+
skills = find_issue_triggered_skills(self.skills_dir)
|
|
360
|
+
return {
|
|
361
|
+
issue_type: self._generate_issue_type_workflow(
|
|
362
|
+
[skill for skill in skills if skill.issue_type == issue_type],
|
|
363
|
+
issue_type,
|
|
364
|
+
)
|
|
365
|
+
for issue_type in ISSUE_TYPES
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
def _generate_issue_type_workflow(
|
|
369
|
+
self,
|
|
370
|
+
skills: list[IssueTriggeredSkill],
|
|
371
|
+
issue_type: str,
|
|
372
|
+
) -> dict[str, Any]:
|
|
373
|
+
"""Infer one status graph from skills for a single issue type."""
|
|
374
|
+
if not skills:
|
|
375
|
+
return {"nodes": [], "edges": [], "warnings": []}
|
|
376
|
+
|
|
377
|
+
documents = []
|
|
378
|
+
skill_documents = {}
|
|
379
|
+
for skill in skills:
|
|
380
|
+
statuses = ", ".join(skill.statuses)
|
|
381
|
+
skill_document = skill.path.read_text()
|
|
382
|
+
skill_documents[skill.name.casefold()] = (skill, skill_document)
|
|
383
|
+
skill_documents[skill.slug.casefold()] = (skill, skill_document)
|
|
384
|
+
documents.append(
|
|
385
|
+
f"## Skill: {skill.name}\nEntry statuses: {statuses}\n\n"
|
|
386
|
+
f"{skill_document}"
|
|
387
|
+
)
|
|
388
|
+
prompt = (
|
|
389
|
+
f"Build the {issue_type} workflow represented by the issue-trigger skills below. "
|
|
390
|
+
"The frontmatter statuses are entry points only; infer outgoing status "
|
|
391
|
+
"transitions from the human instructions in each complete skill. Transitions "
|
|
392
|
+
"must be defined directly in skill text or they do not exist. Never invent or "
|
|
393
|
+
"infer a status name that is not written in the supplied skill documents. "
|
|
394
|
+
"Return only JSON with this shape: "
|
|
395
|
+
'{"statuses":["..."],"transitions":['
|
|
396
|
+
'{"source":"...","target":"...","label":"skill name",'
|
|
397
|
+
'"evidence":"exact quote from that skill"}],'
|
|
398
|
+
'"final_statuses":["..."]}. Every status must be copied exactly from the '
|
|
399
|
+
"skill documents. Every transition must connect two listed statuses and its "
|
|
400
|
+
"label must be the skill that defines it. Its source must be one of that "
|
|
401
|
+
"skill's Entry statuses. evidence must be a verbatim quote from that same "
|
|
402
|
+
"skill which explicitly names the target status. statuses must contain "
|
|
403
|
+
"every status named in workflow instructions, even when no skill handles it. "
|
|
404
|
+
"Preserve mandatory status changes in execution order. If a skill says work "
|
|
405
|
+
"must start in an intermediate status before later moving to another status, "
|
|
406
|
+
"emit consecutive transitions through that intermediate status and do not "
|
|
407
|
+
"emit a direct transition that bypasses it. "
|
|
408
|
+
"Order statuses by the primary forward workflow so a return or rework "
|
|
409
|
+
"transition targets an earlier item in the statuses list. "
|
|
410
|
+
"final_statuses must contain only statuses explicitly described as completion "
|
|
411
|
+
"or handoff to a human. Do not include prose or non-status process steps.\n\n"
|
|
412
|
+
+ "\n\n".join(documents)
|
|
413
|
+
)
|
|
414
|
+
agent_type = _CODING_AGENTS.get(self.context.settings.coding_agent)
|
|
415
|
+
if agent_type is None:
|
|
416
|
+
raise RuntimeError(
|
|
417
|
+
f"Coding agent '{self.context.settings.coding_agent.value}' is not available"
|
|
418
|
+
)
|
|
419
|
+
agent = agent_type(self.context.settings, self.root)
|
|
420
|
+
validation_error = ""
|
|
421
|
+
for attempt in range(2):
|
|
422
|
+
request = prompt
|
|
423
|
+
if validation_error:
|
|
424
|
+
request += (
|
|
425
|
+
"\n\nYour previous response was invalid: "
|
|
426
|
+
f"{validation_error}. Return corrected JSON only."
|
|
427
|
+
)
|
|
428
|
+
response = agent.run(request, str(uuid.uuid4()))
|
|
429
|
+
payload_text = response.strip()
|
|
430
|
+
if payload_text.startswith("```") and payload_text.endswith("```"):
|
|
431
|
+
payload_text = re.sub(
|
|
432
|
+
r"^```(?:json)?\s*|\s*```$", "", payload_text,
|
|
433
|
+
flags=re.IGNORECASE,
|
|
434
|
+
)
|
|
435
|
+
try:
|
|
436
|
+
payload = json.loads(payload_text)
|
|
437
|
+
if not isinstance(payload, dict):
|
|
438
|
+
raise ValueError(
|
|
439
|
+
"Coding agent did not return a workflow object")
|
|
440
|
+
statuses = _string_values(payload.get("statuses"))
|
|
441
|
+
transitions = _transition_values(payload.get("transitions"))
|
|
442
|
+
declared_statuses = {
|
|
443
|
+
status.casefold() for status in statuses
|
|
444
|
+
}
|
|
445
|
+
for transition in transitions:
|
|
446
|
+
if (transition["source"].casefold() not in declared_statuses
|
|
447
|
+
or transition["target"].casefold() not in declared_statuses):
|
|
448
|
+
raise ValueError(
|
|
449
|
+
"each transition must connect two declared statuses")
|
|
450
|
+
label = transition["label"]
|
|
451
|
+
evidence = transition["evidence"]
|
|
452
|
+
skill_entry = skill_documents.get(label.casefold())
|
|
453
|
+
if not label or skill_entry is None:
|
|
454
|
+
raise ValueError(
|
|
455
|
+
"each transition label must name its defining skill")
|
|
456
|
+
skill, skill_document = skill_entry
|
|
457
|
+
if not any(
|
|
458
|
+
transition["source"].casefold() == status.casefold()
|
|
459
|
+
for status in skill.statuses
|
|
460
|
+
):
|
|
461
|
+
raise ValueError(
|
|
462
|
+
f"transition source is not an entry status of {skill.name}")
|
|
463
|
+
if not evidence or evidence not in skill_document:
|
|
464
|
+
raise ValueError(
|
|
465
|
+
f"transition evidence is not an exact quote from {skill.name}")
|
|
466
|
+
if transition["target"].casefold() not in evidence.casefold():
|
|
467
|
+
raise ValueError(
|
|
468
|
+
f"transition evidence does not name its target for {skill.name}")
|
|
469
|
+
final_statuses = _string_values(payload.get("final_statuses"))
|
|
470
|
+
if any(
|
|
471
|
+
status.casefold() not in declared_statuses
|
|
472
|
+
for status in final_statuses
|
|
473
|
+
):
|
|
474
|
+
raise ValueError(
|
|
475
|
+
"each final status must be a declared status")
|
|
476
|
+
break
|
|
477
|
+
except (json.JSONDecodeError, ValueError) as error:
|
|
478
|
+
validation_error = str(error)
|
|
479
|
+
if attempt == 1:
|
|
480
|
+
raise ValueError(
|
|
481
|
+
f"Coding agent returned invalid workflow data: {error}"
|
|
482
|
+
) from error
|
|
483
|
+
|
|
484
|
+
transitions = _remove_redundant_skill_transitions(transitions)
|
|
485
|
+
|
|
486
|
+
status_ids = {
|
|
487
|
+
status.casefold(): f"status-{index}"
|
|
488
|
+
for index, status in enumerate(statuses)
|
|
489
|
+
}
|
|
490
|
+
status_order = {
|
|
491
|
+
status.casefold(): index for index, status in enumerate(statuses)
|
|
492
|
+
}
|
|
493
|
+
grouped_transitions: dict[tuple[str, str], dict[str, Any]] = {}
|
|
494
|
+
for transition in transitions:
|
|
495
|
+
key = (transition["source"].casefold(),
|
|
496
|
+
transition["target"].casefold())
|
|
497
|
+
grouped = grouped_transitions.setdefault(key, {
|
|
498
|
+
"source": transition["source"],
|
|
499
|
+
"target": transition["target"],
|
|
500
|
+
"labels": [],
|
|
501
|
+
})
|
|
502
|
+
if (transition["label"]
|
|
503
|
+
and transition["label"] not in grouped["labels"]):
|
|
504
|
+
grouped["labels"].append(transition["label"])
|
|
505
|
+
triggered = {status.casefold()
|
|
506
|
+
for skill in skills for status in skill.statuses}
|
|
507
|
+
final = {status.casefold() for status in final_statuses}
|
|
508
|
+
# Statuses no issue-trigger skill picks up are flagged on the graph
|
|
509
|
+
# node itself instead of as a warning callout above the diagram.
|
|
510
|
+
unhandled = {
|
|
511
|
+
status.casefold() for status in statuses
|
|
512
|
+
if status.casefold() not in triggered and status.casefold() not in final
|
|
513
|
+
}
|
|
514
|
+
warnings: list[str] = []
|
|
515
|
+
disconnected = len(statuses) > 1 and not transitions
|
|
516
|
+
if disconnected:
|
|
517
|
+
warnings.append(
|
|
518
|
+
"Workflow statuses are disconnected: no status transitions were found."
|
|
519
|
+
)
|
|
520
|
+
if not final_statuses:
|
|
521
|
+
warnings.append(
|
|
522
|
+
"No final human-handoff status is defined in the issue skill workflow."
|
|
523
|
+
)
|
|
524
|
+
nodes = [
|
|
525
|
+
{
|
|
526
|
+
"id": status_ids[status.casefold()],
|
|
527
|
+
"position": {"x": index * WORKFLOW_NODE_SPACING, "y": 0},
|
|
528
|
+
"sourcePosition": "right",
|
|
529
|
+
"targetPosition": "left",
|
|
530
|
+
"data": {"label": status},
|
|
531
|
+
"className": " ".join(
|
|
532
|
+
["workflow-node"]
|
|
533
|
+
+ (["workflow-node--disconnected"] if disconnected else [])
|
|
534
|
+
+ (["workflow-node--unhandled"]
|
|
535
|
+
if status.casefold() in unhandled else [])
|
|
536
|
+
),
|
|
537
|
+
}
|
|
538
|
+
for index, status in enumerate(statuses)
|
|
539
|
+
]
|
|
540
|
+
edges = []
|
|
541
|
+
return_index = 0
|
|
542
|
+
forward_route_index = 0
|
|
543
|
+
for index, transition in enumerate(grouped_transitions.values()):
|
|
544
|
+
source_order = status_order[transition["source"].casefold()]
|
|
545
|
+
target_order = status_order[transition["target"].casefold()]
|
|
546
|
+
is_return = target_order <= source_order
|
|
547
|
+
is_long_forward = target_order > source_order + 1
|
|
548
|
+
color = "#d97706" if is_return else "#167d5a"
|
|
549
|
+
edge_data = {
|
|
550
|
+
"data": {"skills": transition["labels"]},
|
|
551
|
+
"type": "smoothstep",
|
|
552
|
+
"animated": is_return,
|
|
553
|
+
"className": (
|
|
554
|
+
"workflow-edge workflow-edge--return"
|
|
555
|
+
if is_return else "workflow-edge"
|
|
556
|
+
),
|
|
557
|
+
"markerEnd": {"type": "arrowclosed", "color": color},
|
|
558
|
+
"style": {
|
|
559
|
+
"stroke": color,
|
|
560
|
+
"strokeWidth": 2,
|
|
561
|
+
**({"strokeDasharray": "8 6"} if is_return else {}),
|
|
562
|
+
},
|
|
563
|
+
}
|
|
564
|
+
aria_label = (
|
|
565
|
+
f"{transition['source']} to {transition['target']}"
|
|
566
|
+
+ (f" via {', '.join(transition['labels'])}"
|
|
567
|
+
if transition["labels"] else "")
|
|
568
|
+
)
|
|
569
|
+
label = ", ".join(transition["labels"])
|
|
570
|
+
label_data = ({
|
|
571
|
+
"label": label,
|
|
572
|
+
"labelStyle": {
|
|
573
|
+
"fill": "#d7e1dc",
|
|
574
|
+
"fontSize": 12,
|
|
575
|
+
"fontWeight": 600,
|
|
576
|
+
},
|
|
577
|
+
"labelBgStyle": {
|
|
578
|
+
"fill": "#17211d",
|
|
579
|
+
"fillOpacity": 0.96,
|
|
580
|
+
},
|
|
581
|
+
"labelBgPadding": [6, 4],
|
|
582
|
+
"labelBgBorderRadius": 4,
|
|
583
|
+
} if label else {})
|
|
584
|
+
if not is_return and not is_long_forward:
|
|
585
|
+
edges.append({
|
|
586
|
+
**edge_data,
|
|
587
|
+
**label_data,
|
|
588
|
+
"id": f"transition-{index}",
|
|
589
|
+
"source": status_ids[transition["source"].casefold()],
|
|
590
|
+
"target": status_ids[transition["target"].casefold()],
|
|
591
|
+
"ariaLabel": aria_label,
|
|
592
|
+
})
|
|
593
|
+
continue
|
|
594
|
+
|
|
595
|
+
if is_long_forward:
|
|
596
|
+
route_y = -180 - forward_route_index * 90
|
|
597
|
+
route_ids = [
|
|
598
|
+
f"forward-route-{forward_route_index}-out",
|
|
599
|
+
f"forward-route-{forward_route_index}-in",
|
|
600
|
+
]
|
|
601
|
+
route_points = [
|
|
602
|
+
(
|
|
603
|
+
route_ids[0],
|
|
604
|
+
source_order * WORKFLOW_NODE_SPACING
|
|
605
|
+
+ WORKFLOW_NODE_SPACING - WORKFLOW_NODE_CENTER_OFFSET,
|
|
606
|
+
),
|
|
607
|
+
(
|
|
608
|
+
route_ids[1],
|
|
609
|
+
target_order * WORKFLOW_NODE_SPACING
|
|
610
|
+
- WORKFLOW_NODE_CENTER_OFFSET,
|
|
611
|
+
),
|
|
612
|
+
]
|
|
613
|
+
for route_id, route_x in route_points:
|
|
614
|
+
nodes.append({
|
|
615
|
+
"id": route_id,
|
|
616
|
+
"position": {"x": route_x, "y": route_y},
|
|
617
|
+
"sourcePosition": "right",
|
|
618
|
+
"targetPosition": "left",
|
|
619
|
+
"data": {"label": ""},
|
|
620
|
+
"className": (
|
|
621
|
+
"workflow-route-node "
|
|
622
|
+
"workflow-route-node--forward"
|
|
623
|
+
),
|
|
624
|
+
"selectable": False,
|
|
625
|
+
"draggable": False,
|
|
626
|
+
"style": {
|
|
627
|
+
"background": "transparent",
|
|
628
|
+
"border": "none",
|
|
629
|
+
"height": 1,
|
|
630
|
+
"minHeight": 1,
|
|
631
|
+
"opacity": 1,
|
|
632
|
+
"padding": 0,
|
|
633
|
+
"width": 1,
|
|
634
|
+
},
|
|
635
|
+
})
|
|
636
|
+
edges.extend([
|
|
637
|
+
{
|
|
638
|
+
**edge_data,
|
|
639
|
+
**label_data,
|
|
640
|
+
"id": f"transition-{index}-out",
|
|
641
|
+
"source": status_ids[transition["source"].casefold()],
|
|
642
|
+
"target": route_ids[0],
|
|
643
|
+
},
|
|
644
|
+
{
|
|
645
|
+
**edge_data,
|
|
646
|
+
"id": f"transition-{index}-route",
|
|
647
|
+
"source": route_ids[0],
|
|
648
|
+
"target": route_ids[1],
|
|
649
|
+
},
|
|
650
|
+
{
|
|
651
|
+
**edge_data,
|
|
652
|
+
"id": f"transition-{index}-in",
|
|
653
|
+
"source": route_ids[1],
|
|
654
|
+
"target": status_ids[transition["target"].casefold()],
|
|
655
|
+
"ariaLabel": aria_label,
|
|
656
|
+
},
|
|
657
|
+
])
|
|
658
|
+
edges[-3].pop("markerEnd", None)
|
|
659
|
+
edges[-2].pop("markerEnd", None)
|
|
660
|
+
forward_route_index += 1
|
|
661
|
+
continue
|
|
662
|
+
|
|
663
|
+
route_id = f"return-route-{return_index}"
|
|
664
|
+
nodes.append({
|
|
665
|
+
"id": route_id,
|
|
666
|
+
"position": {
|
|
667
|
+
"x": (
|
|
668
|
+
(source_order + target_order)
|
|
669
|
+
* WORKFLOW_NODE_SPACING / 2
|
|
670
|
+
+ WORKFLOW_NODE_CENTER_OFFSET
|
|
671
|
+
),
|
|
672
|
+
"y": 180 + return_index * 90,
|
|
673
|
+
},
|
|
674
|
+
"sourcePosition": "left",
|
|
675
|
+
"targetPosition": "right",
|
|
676
|
+
"data": {"label": ""},
|
|
677
|
+
"className": (
|
|
678
|
+
"workflow-route-node "
|
|
679
|
+
"workflow-route-node--return"
|
|
680
|
+
),
|
|
681
|
+
"selectable": False,
|
|
682
|
+
"draggable": False,
|
|
683
|
+
"style": {
|
|
684
|
+
"background": "transparent",
|
|
685
|
+
"border": "none",
|
|
686
|
+
"height": 1,
|
|
687
|
+
"minHeight": 1,
|
|
688
|
+
"opacity": 1,
|
|
689
|
+
"padding": 0,
|
|
690
|
+
"width": 1,
|
|
691
|
+
},
|
|
692
|
+
})
|
|
693
|
+
edges.extend([
|
|
694
|
+
{
|
|
695
|
+
**edge_data,
|
|
696
|
+
**label_data,
|
|
697
|
+
"id": f"transition-{index}-out",
|
|
698
|
+
"source": status_ids[transition["source"].casefold()],
|
|
699
|
+
"target": route_id,
|
|
700
|
+
},
|
|
701
|
+
{
|
|
702
|
+
**edge_data,
|
|
703
|
+
"id": f"transition-{index}-in",
|
|
704
|
+
"source": route_id,
|
|
705
|
+
"target": status_ids[transition["target"].casefold()],
|
|
706
|
+
"ariaLabel": aria_label,
|
|
707
|
+
},
|
|
708
|
+
])
|
|
709
|
+
edges[-2].pop("markerEnd", None)
|
|
710
|
+
return_index += 1
|
|
711
|
+
return {"nodes": nodes, "edges": edges, "warnings": warnings}
|
|
712
|
+
|
|
713
|
+
def load_skill(self, slug: str) -> dict[str, str]:
|
|
714
|
+
path = self.skills_dir / slug / "SKILL.md"
|
|
715
|
+
frontmatter, body = parse_skill(path.read_text())
|
|
716
|
+
extra = {key: value for key, value in frontmatter.items()
|
|
717
|
+
if key not in MANAGED}
|
|
718
|
+
return {
|
|
719
|
+
"slug": slug,
|
|
720
|
+
"name": str(frontmatter.get("name", slug)),
|
|
721
|
+
"description": str(frontmatter.get("description", "")),
|
|
722
|
+
"model": str(frontmatter.get("model", "") or ""),
|
|
723
|
+
"type": infer_skill_type(frontmatter),
|
|
724
|
+
"cron": str(frontmatter.get("x-codee-cron", frontmatter.get("cron", "0 0 * * *"))),
|
|
725
|
+
"email": str(frontmatter.get("x-codee-email-address", "")),
|
|
726
|
+
"sqs": str(frontmatter.get("x-codee-aws-sqs-queue", "")),
|
|
727
|
+
"issue_status": _format_issue_status(frontmatter.get("x-codee-issue-status", [])),
|
|
728
|
+
"issue_type": str(frontmatter.get("x-codee-issue-type", "")).strip().lower(),
|
|
729
|
+
"body": body,
|
|
730
|
+
"extra": dump_frontmatter(extra) if extra else "",
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
def create_skill(self, name: str) -> tuple[bool, bool, str, str]:
|
|
734
|
+
slug = slugify(name)
|
|
735
|
+
if not slug:
|
|
736
|
+
return False, False, "Enter a valid skill name", ""
|
|
737
|
+
directory = self.skills_dir / slug
|
|
738
|
+
if directory.exists():
|
|
739
|
+
return False, False, f"{slug} already exists", slug
|
|
740
|
+
directory.mkdir(parents=True)
|
|
741
|
+
saved, pushed, message = self._write_and_push(
|
|
742
|
+
directory / "SKILL.md",
|
|
743
|
+
build_skill({"name": slug, "description": ""}, {}, ""),
|
|
744
|
+
f"skill: create {slug}",
|
|
745
|
+
)
|
|
746
|
+
return saved, pushed, message, slug
|
|
747
|
+
|
|
748
|
+
def save_skill(self, skill: dict[str, str]) -> tuple[bool, bool, str, str]:
|
|
749
|
+
old_slug = skill["slug"]
|
|
750
|
+
name = slugify(skill["name"])
|
|
751
|
+
if not name:
|
|
752
|
+
return False, False, "Enter a valid skill name", old_slug
|
|
753
|
+
|
|
754
|
+
# A caller that leaves `extra` out is not editing the free-form fields,
|
|
755
|
+
# so whatever the file already carries is kept below.
|
|
756
|
+
edits_extra = "extra" in skill
|
|
757
|
+
extra, extra_error = parse_extra_frontmatter(skill.get("extra", ""))
|
|
758
|
+
if extra_error:
|
|
759
|
+
return False, False, extra_error, old_slug
|
|
760
|
+
|
|
761
|
+
frontmatter: dict[str, Any] = {
|
|
762
|
+
"name": name,
|
|
763
|
+
"description": skill["description"],
|
|
764
|
+
}
|
|
765
|
+
# Left out entirely when unset, so the skill keeps running on whatever
|
|
766
|
+
# the agent defaults to rather than on an empty model id.
|
|
767
|
+
model = skill.get("model", "").strip()
|
|
768
|
+
if model:
|
|
769
|
+
frontmatter["model"] = model
|
|
770
|
+
skill_type = skill["type"]
|
|
771
|
+
if skill_type == "slash command":
|
|
772
|
+
frontmatter["disable-model-invocation"] = True
|
|
773
|
+
elif skill_type == "issue trigger":
|
|
774
|
+
issue_type = skill.get("issue_type", "").strip().lower()
|
|
775
|
+
if issue_type not in ISSUE_TYPES:
|
|
776
|
+
return False, False, "Select an issue type: story or task", old_slug
|
|
777
|
+
frontmatter.update({
|
|
778
|
+
"disable-model-invocation": True,
|
|
779
|
+
"x-codee-trigger": "issue",
|
|
780
|
+
"x-codee-issue-status": [
|
|
781
|
+
status.strip() for status in skill["issue_status"].split(",")
|
|
782
|
+
if status.strip()
|
|
783
|
+
],
|
|
784
|
+
"x-codee-issue-type": issue_type,
|
|
785
|
+
})
|
|
786
|
+
elif skill_type == "cron trigger":
|
|
787
|
+
frontmatter.update({
|
|
788
|
+
"disable-model-invocation": True,
|
|
789
|
+
"x-codee-trigger": "cron",
|
|
790
|
+
"x-codee-cron": skill["cron"],
|
|
791
|
+
})
|
|
792
|
+
elif skill_type == "email trigger":
|
|
793
|
+
frontmatter.update({
|
|
794
|
+
"disable-model-invocation": True,
|
|
795
|
+
"x-codee-trigger": "email",
|
|
796
|
+
"x-codee-email-address": skill["email"],
|
|
797
|
+
})
|
|
798
|
+
elif skill_type == "aws-sqs trigger":
|
|
799
|
+
frontmatter.update({
|
|
800
|
+
"disable-model-invocation": True,
|
|
801
|
+
"x-codee-trigger": "aws-sqs",
|
|
802
|
+
"x-codee-aws-sqs-queue": skill["sqs"],
|
|
803
|
+
})
|
|
804
|
+
|
|
805
|
+
current_path = self.skills_dir / old_slug / "SKILL.md"
|
|
806
|
+
destination = self.skills_dir / name
|
|
807
|
+
if name != old_slug:
|
|
808
|
+
if destination.exists():
|
|
809
|
+
return False, False, f"{name} already exists", old_slug
|
|
810
|
+
current_path.parent.rename(destination)
|
|
811
|
+
current_path = destination / "SKILL.md"
|
|
812
|
+
|
|
813
|
+
if not edits_extra:
|
|
814
|
+
existing, _ = parse_skill(current_path.read_text())
|
|
815
|
+
extra = {key: value for key, value in existing.items()
|
|
816
|
+
if key not in MANAGED}
|
|
817
|
+
action =f"rename {old_slug} -> {name}" if name != old_slug else f"update {name}"
|
|
818
|
+
saved, pushed, message = self._write_and_push(
|
|
819
|
+
current_path,
|
|
820
|
+
build_skill(frontmatter, extra, skill["body"]),
|
|
821
|
+
f"skill: {action}",
|
|
822
|
+
)
|
|
823
|
+
return saved, pushed, message, name
|
|
824
|
+
|
|
825
|
+
def delete_skill(self, slug: str) -> tuple[bool, bool, str]:
|
|
826
|
+
directory = self.skills_dir / slug
|
|
827
|
+
if not slug or not directory.is_dir():
|
|
828
|
+
return False, False, f"{slug or 'Skill'} does not exist"
|
|
829
|
+
shutil.rmtree(directory)
|
|
830
|
+
pushed, output = self._git_push(f"skill: delete {slug}")
|
|
831
|
+
if pushed:
|
|
832
|
+
return True, True, f"Deleted {slug}"
|
|
833
|
+
return True, False, f"Deleted {slug} locally, but Git push failed: {output}"
|
|
834
|
+
|
|
835
|
+
def load_agents(self) -> str:
|
|
836
|
+
"""Read AGENTS.md verbatim: it is plain text, not a skill document."""
|
|
837
|
+
return self.agents_file.read_text() if self.agents_file.exists() else ""
|
|
838
|
+
|
|
839
|
+
def save_agents(self, content: str) -> tuple[bool, bool, str]:
|
|
840
|
+
return self._write_and_push(
|
|
841
|
+
self.agents_file, content, f"agents: update {AGENTS_FILE}"
|
|
842
|
+
)
|
|
843
|
+
|
|
844
|
+
def force_run_skill(self, slug: str) -> None:
|
|
845
|
+
trigger_cron_skills.request_force_run(
|
|
846
|
+
self.skills_dir / slug / "SKILL.md", main_context=self.context
|
|
847
|
+
)
|
|
848
|
+
|
|
849
|
+
def describe_cron(self, expression: str) -> str:
|
|
850
|
+
return describe_cron(expression) or "Unrecognized cron expression"
|
|
851
|
+
|
|
852
|
+
def list_memories(self) -> list[dict[str, Any]]:
|
|
853
|
+
if not self.memory_index.exists() or not self.memory_index.read_text().strip():
|
|
854
|
+
return []
|
|
855
|
+
return parse_index(self.memory_index.read_text())
|
|
856
|
+
|
|
857
|
+
def load_memory(self, filename: str) -> str:
|
|
858
|
+
path = self.memory_dir / filename
|
|
859
|
+
return path.read_text() if path.exists() else ""
|
|
860
|
+
|
|
861
|
+
def save_memory(self, filename: str, content: str) -> tuple[bool, bool, str]:
|
|
862
|
+
return self._write_and_push(
|
|
863
|
+
self.memory_dir / filename, content, f"memory: update {filename}"
|
|
864
|
+
)
|
|
865
|
+
|
|
866
|
+
def delete_memory(self, filename: str, raw: str) -> tuple[bool, bool, str]:
|
|
867
|
+
lines = self.memory_index.read_text().splitlines(keepends=True)
|
|
868
|
+
lines = [line for line in lines if line.rstrip("\n") != raw]
|
|
869
|
+
self.memory_index.write_text("".join(lines))
|
|
870
|
+
(self.memory_dir / filename).unlink(missing_ok=True)
|
|
871
|
+
pushed, output = self._git_push(f"memory: delete {filename}")
|
|
872
|
+
if pushed:
|
|
873
|
+
return True, True, f"Deleted {filename}"
|
|
874
|
+
return True, False, f"Deleted {filename} locally, but Git push failed: {output}"
|
|
875
|
+
|
|
876
|
+
def dashboard(self) -> dict[str, Any]:
|
|
877
|
+
return {
|
|
878
|
+
"active": [
|
|
879
|
+
{**job, "elapsed_label": runs_db.fmt_elapsed(job["elapsed"])}
|
|
880
|
+
for job in runs_db.active_jobs(main_context=self.context)
|
|
881
|
+
],
|
|
882
|
+
"counts": runs_db.counts(main_context=self.context),
|
|
883
|
+
"hourly": runs_db.runs_by_hour(main_context=self.context),
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
def recent_runs(self, limit: int = 100, offset: int = 0) -> list[dict[str, Any]]:
|
|
887
|
+
return runs_db.recent_runs(limit, offset, main_context=self.context)
|
|
888
|
+
|
|
889
|
+
def load_settings(self) -> Settings:
|
|
890
|
+
self.context.settings = load_settings(self.data_dir)
|
|
891
|
+
return self.context.settings
|
|
892
|
+
|
|
893
|
+
def save_settings(
|
|
894
|
+
self,
|
|
895
|
+
tasks_provider: str,
|
|
896
|
+
coding_agent: str,
|
|
897
|
+
max_parallel_agents: int,
|
|
898
|
+
credentials: dict[str, str],
|
|
899
|
+
) -> None:
|
|
900
|
+
current = self.context.settings
|
|
901
|
+
all_credentials = dict(current.credentials)
|
|
902
|
+
all_credentials[tasks_provider] = credentials
|
|
903
|
+
self.context.settings = Settings(
|
|
904
|
+
tasks_provider=TasksProvider(tasks_provider),
|
|
905
|
+
coding_agent=CodingAgent(coding_agent),
|
|
906
|
+
credentials=all_credentials,
|
|
907
|
+
max_parallel_agents=max(1, max_parallel_agents),
|
|
908
|
+
)
|
|
909
|
+
save_settings(self.data_dir, self.context.settings)
|
|
910
|
+
|
|
911
|
+
# --- Azure DevOps OAuth -------------------------------------------------
|
|
912
|
+
|
|
913
|
+
def admin_base_url(self) -> str:
|
|
914
|
+
"""Origin the browser reaches this admin UI on.
|
|
915
|
+
|
|
916
|
+
Taken from the port the launcher passed through ``REFLEX_API_URL`` so the
|
|
917
|
+
redirect URI follows ``codee-admin --port``. Set ``CODEE_ADMIN_BASE_URL``
|
|
918
|
+
when the UI is reached through some other host or scheme.
|
|
919
|
+
"""
|
|
920
|
+
override = os.environ.get("CODEE_ADMIN_BASE_URL", "").strip()
|
|
921
|
+
if override:
|
|
922
|
+
return override.rstrip("/")
|
|
923
|
+
port = urlparse(os.environ.get(
|
|
924
|
+
"REFLEX_API_URL", "")).port or DEFAULT_ADMIN_PORT
|
|
925
|
+
return f"http://localhost:{port}"
|
|
926
|
+
|
|
927
|
+
def azure_redirect_uri(self) -> str:
|
|
928
|
+
"""The redirect URI to register on the Entra app, and to send to Entra."""
|
|
929
|
+
return self.admin_base_url() + azure_oauth.CALLBACK_PATH
|
|
930
|
+
|
|
931
|
+
def azure_connection(self) -> dict[str, Any]:
|
|
932
|
+
"""Whether Azure DevOps is connected, and as whom, for the settings page."""
|
|
933
|
+
tokens = oauth_tokens.load_tokens(
|
|
934
|
+
azure_oauth.PROVIDER, main_context=self.context)
|
|
935
|
+
if not tokens:
|
|
936
|
+
return {"connected": False, "account": "", "expires_label": ""}
|
|
937
|
+
return {
|
|
938
|
+
"connected": True,
|
|
939
|
+
"account": tokens.get("account") or "",
|
|
940
|
+
"expires_label": _format_token_expiry(tokens.get("expires_at")),
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
def start_azure_authorization(self) -> tuple[bool, str]:
|
|
944
|
+
"""Open an authorization: returns (True, url) or (False, error message).
|
|
945
|
+
|
|
946
|
+
The pending state and PKCE verifier go to SQLite rather than to the UI
|
|
947
|
+
state, because the callback arrives as a plain HTTP request that has no
|
|
948
|
+
access to the Reflex session that started the flow.
|
|
949
|
+
"""
|
|
950
|
+
config = azure_oauth.OAuthConfig.from_settings(self.load_settings())
|
|
951
|
+
if not config.is_complete():
|
|
952
|
+
return False, ("Fill in organization URL, project, client ID and "
|
|
953
|
+
"client secret before connecting.")
|
|
954
|
+
state = azure_oauth.new_state()
|
|
955
|
+
code_verifier = azure_oauth.new_code_verifier()
|
|
956
|
+
redirect_uri = self.azure_redirect_uri()
|
|
957
|
+
oauth_tokens.create_pending(
|
|
958
|
+
azure_oauth.PROVIDER, state, code_verifier, redirect_uri,
|
|
959
|
+
main_context=self.context)
|
|
960
|
+
return True, azure_oauth.build_authorization_url(
|
|
961
|
+
config, redirect_uri, state, code_verifier)
|
|
962
|
+
|
|
963
|
+
def complete_azure_authorization(self, code: str, state: str) -> tuple[bool, str]:
|
|
964
|
+
"""Exchange the callback's code for tokens and store them."""
|
|
965
|
+
# Re-read from disk: this runs on the callback request, not on the
|
|
966
|
+
# session that started the flow, so in-memory settings may be stale.
|
|
967
|
+
config = azure_oauth.OAuthConfig.from_settings(self.load_settings())
|
|
968
|
+
pending = oauth_tokens.consume_pending(
|
|
969
|
+
azure_oauth.PROVIDER, state, main_context=self.context)
|
|
970
|
+
if pending is None:
|
|
971
|
+
return False, ("That authorization link was already used or expired. "
|
|
972
|
+
"Start the connection again.")
|
|
973
|
+
try:
|
|
974
|
+
tokens = azure_oauth.exchange_code(
|
|
975
|
+
config, pending["redirect_uri"], code, pending["code_verifier"])
|
|
976
|
+
except azure_oauth.AzureDevOpsAuthError as exc:
|
|
977
|
+
return False, str(exc)
|
|
978
|
+
account = azure_oauth.fetch_account(tokens["access_token"])
|
|
979
|
+
azure_oauth.AzureDevOpsAuth(config, self.context).store(
|
|
980
|
+
tokens, account=account)
|
|
981
|
+
return True, (f"Connected to Azure DevOps as {account}"
|
|
982
|
+
if account else "Connected to Azure DevOps")
|
|
983
|
+
|
|
984
|
+
def disconnect_azure(self) -> None:
|
|
985
|
+
"""Drop the stored tokens. The app registration itself is untouched."""
|
|
986
|
+
oauth_tokens.delete_tokens(
|
|
987
|
+
azure_oauth.PROVIDER, main_context=self.context)
|
|
988
|
+
|
|
989
|
+
|
|
990
|
+
def _format_token_expiry(expires_at: str | None) -> str:
|
|
991
|
+
"""Human-readable life left in the access token; it is refreshed on demand."""
|
|
992
|
+
if not expires_at:
|
|
993
|
+
return "refreshes on next check"
|
|
994
|
+
try:
|
|
995
|
+
deadline = datetime.fromisoformat(expires_at)
|
|
996
|
+
except (TypeError, ValueError):
|
|
997
|
+
return "refreshes on next check"
|
|
998
|
+
if deadline.tzinfo is None:
|
|
999
|
+
deadline = deadline.replace(tzinfo=timezone.utc)
|
|
1000
|
+
minutes = int((deadline - datetime.now(timezone.utc)).total_seconds() // 60)
|
|
1001
|
+
if minutes < 1:
|
|
1002
|
+
return "refreshes on next check"
|
|
1003
|
+
if minutes < 60:
|
|
1004
|
+
return f"access token valid for {minutes} min"
|
|
1005
|
+
return f"access token valid for {minutes // 60}h {minutes % 60}m"
|