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/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"