codee-agent 0.2.0__tar.gz → 0.3.0__tar.gz

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 (68) hide show
  1. {codee_agent-0.2.0 → codee_agent-0.3.0}/PKG-INFO +1 -1
  2. {codee_agent-0.2.0 → codee_agent-0.3.0}/pyproject.toml +1 -1
  3. {codee_agent-0.2.0 → codee_agent-0.3.0}/src/codee/admin.py +36 -23
  4. {codee_agent-0.2.0 → codee_agent-0.3.0}/src/codee/admin_cli.py +2 -2
  5. {codee_agent-0.2.0 → codee_agent-0.3.0}/src/codee/admin_service.py +81 -7
  6. {codee_agent-0.2.0 → codee_agent-0.3.0}/src/codee/test_admin_service.py +112 -8
  7. {codee_agent-0.2.0 → codee_agent-0.3.0}/LICENSE +0 -0
  8. {codee_agent-0.2.0 → codee_agent-0.3.0}/README.md +0 -0
  9. {codee_agent-0.2.0 → codee_agent-0.3.0}/src/codee/.gitignore +0 -0
  10. {codee_agent-0.2.0 → codee_agent-0.3.0}/src/codee/__init__.py +0 -0
  11. {codee_agent-0.2.0 → codee_agent-0.3.0}/src/codee/admin_api.py +0 -0
  12. {codee_agent-0.2.0 → codee_agent-0.3.0}/src/codee/executor.py +0 -0
  13. {codee_agent-0.2.0 → codee_agent-0.3.0}/src/codee/init_cli.py +0 -0
  14. {codee_agent-0.2.0 → codee_agent-0.3.0}/src/codee/lib/__init__.py +0 -0
  15. {codee_agent-0.2.0 → codee_agent-0.3.0}/src/codee/lib/cron_describe.py +0 -0
  16. {codee_agent-0.2.0 → codee_agent-0.3.0}/src/codee/lib/runs_db.py +0 -0
  17. {codee_agent-0.2.0 → codee_agent-0.3.0}/src/codee/lib/test_runs_db.py +0 -0
  18. {codee_agent-0.2.0 → codee_agent-0.3.0}/src/codee/lib/test_trigger_cron_skills.py +0 -0
  19. {codee_agent-0.2.0 → codee_agent-0.3.0}/src/codee/lib/test_trigger_issue_skills.py +0 -0
  20. {codee_agent-0.2.0 → codee_agent-0.3.0}/src/codee/lib/trigger_aws_sqs_skills.py +0 -0
  21. {codee_agent-0.2.0 → codee_agent-0.3.0}/src/codee/lib/trigger_cron_skills.py +0 -0
  22. {codee_agent-0.2.0 → codee_agent-0.3.0}/src/codee/lib/trigger_email_skills.py +0 -0
  23. {codee_agent-0.2.0 → codee_agent-0.3.0}/src/codee/lib/trigger_issue_skills.py +0 -0
  24. {codee_agent-0.2.0 → codee_agent-0.3.0}/src/codee/mail_server.py +0 -0
  25. {codee_agent-0.2.0 → codee_agent-0.3.0}/src/codee/start_cli.py +0 -0
  26. {codee_agent-0.2.0 → codee_agent-0.3.0}/src/codee/templates/AGENTS.md +0 -0
  27. {codee_agent-0.2.0 → codee_agent-0.3.0}/src/codee/templates/CLAUDE.md +0 -0
  28. {codee_agent-0.2.0 → codee_agent-0.3.0}/src/codee/templates/skills/aws-sqs-alarm-response/SKILL.md +0 -0
  29. {codee_agent-0.2.0 → codee_agent-0.3.0}/src/codee/templates/skills/cron-research-5xx-errors/SKILL.md +0 -0
  30. {codee_agent-0.2.0 → codee_agent-0.3.0}/src/codee/templates/skills/story-code-reviewer/SKILL.md +0 -0
  31. {codee_agent-0.2.0 → codee_agent-0.3.0}/src/codee/templates/skills/story-developer/SKILL.md +0 -0
  32. {codee_agent-0.2.0 → codee_agent-0.3.0}/src/codee/templates/skills/story-planner/SKILL.md +0 -0
  33. {codee_agent-0.2.0 → codee_agent-0.3.0}/src/codee/templates/skills/story-planner/assets/readme-template.md +0 -0
  34. {codee_agent-0.2.0 → codee_agent-0.3.0}/src/codee/templates/skills/story-qa/SKILL.md +0 -0
  35. {codee_agent-0.2.0 → codee_agent-0.3.0}/src/codee/templates/skills/task-developer/SKILL.md +0 -0
  36. {codee_agent-0.2.0 → codee_agent-0.3.0}/src/codee/templates/skills/task-qa/SKILL.md +0 -0
  37. {codee_agent-0.2.0 → codee_agent-0.3.0}/src/codee/test_admin_api.py +0 -0
  38. {codee_agent-0.2.0 → codee_agent-0.3.0}/src/codee/test_admin_cli.py +0 -0
  39. {codee_agent-0.2.0 → codee_agent-0.3.0}/src/codee/test_executor.py +0 -0
  40. {codee_agent-0.2.0 → codee_agent-0.3.0}/src/codee/test_init_cli.py +0 -0
  41. {codee_agent-0.2.0 → codee_agent-0.3.0}/src/codee/test_memory_index.py +0 -0
  42. {codee_agent-0.2.0 → codee_agent-0.3.0}/src/codee/test_start_cli.py +0 -0
  43. {codee_agent-0.2.0 → codee_agent-0.3.0}/src/codee/workflow_graph.py +0 -0
  44. {codee_agent-0.2.0 → codee_agent-0.3.0}/src/codee_admin/__init__.py +0 -0
  45. {codee_agent-0.2.0 → codee_agent-0.3.0}/src/codee_admin/codee_admin.py +0 -0
  46. {codee_agent-0.2.0 → codee_agent-0.3.0}/src/codee_agent_abstract/__init__.py +0 -0
  47. {codee_agent-0.2.0 → codee_agent-0.3.0}/src/codee_agent_abstract/provider.py +0 -0
  48. {codee_agent-0.2.0 → codee_agent-0.3.0}/src/codee_agent_claude_code/__init__.py +0 -0
  49. {codee_agent-0.2.0 → codee_agent-0.3.0}/src/codee_agent_claude_code/provider.py +0 -0
  50. {codee_agent-0.2.0 → codee_agent-0.3.0}/src/codee_agent_github_copilot/__init__.py +0 -0
  51. {codee_agent-0.2.0 → codee_agent-0.3.0}/src/codee_agent_github_copilot/provider.py +0 -0
  52. {codee_agent-0.2.0 → codee_agent-0.3.0}/src/codee_agent_github_copilot/test.py +0 -0
  53. {codee_agent-0.2.0 → codee_agent-0.3.0}/src/codee_database/__init__.py +0 -0
  54. {codee_agent-0.2.0 → codee_agent-0.3.0}/src/codee_database/database.py +0 -0
  55. {codee_agent-0.2.0 → codee_agent-0.3.0}/src/codee_database/oauth_tokens.py +0 -0
  56. {codee_agent-0.2.0 → codee_agent-0.3.0}/src/codee_main_context/__init__.py +0 -0
  57. {codee_agent-0.2.0 → codee_agent-0.3.0}/src/codee_main_context/context.py +0 -0
  58. {codee_agent-0.2.0 → codee_agent-0.3.0}/src/codee_main_context/logging.py +0 -0
  59. {codee_agent-0.2.0 → codee_agent-0.3.0}/src/codee_main_context/test_logging.py +0 -0
  60. {codee_agent-0.2.0 → codee_agent-0.3.0}/src/codee_tasks_abstract/__init__.py +0 -0
  61. {codee_agent-0.2.0 → codee_agent-0.3.0}/src/codee_tasks_abstract/provider.py +0 -0
  62. {codee_agent-0.2.0 → codee_agent-0.3.0}/src/codee_tasks_azure_devops/__init__.py +0 -0
  63. {codee_agent-0.2.0 → codee_agent-0.3.0}/src/codee_tasks_azure_devops/oauth.py +0 -0
  64. {codee_agent-0.2.0 → codee_agent-0.3.0}/src/codee_tasks_azure_devops/provider.py +0 -0
  65. {codee_agent-0.2.0 → codee_agent-0.3.0}/src/codee_tasks_azure_devops/test.py +0 -0
  66. {codee_agent-0.2.0 → codee_agent-0.3.0}/src/codee_tasks_jira/__init__.py +0 -0
  67. {codee_agent-0.2.0 → codee_agent-0.3.0}/src/codee_tasks_jira/provider.py +0 -0
  68. {codee_agent-0.2.0 → codee_agent-0.3.0}/src/codee_tasks_jira/test.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codee-agent
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: A virtual co-worker that picks up Jira and Azure DevOps tasks and solves them with coding agents.
5
5
  Keywords: ai,agent,jira,azure-devops,claude-code,github-copilot,automation
6
6
  Author: Denis Kibalko
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "codee-agent"
3
- version = "0.2.0"
3
+ version = "0.3.0"
4
4
  description = "A virtual co-worker that picks up Jira and Azure DevOps tasks and solves them with coding agents."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -335,6 +335,11 @@ class AdminState(rx.State):
335
335
  return _save_toast(*SERVICE.save_agents(self.agents_content))
336
336
 
337
337
  def load_memories(self) -> None:
338
+ self.coding_agent = SERVICE.load_settings().coding_agent.value
339
+ if self.coding_agent == "github_copilot":
340
+ self.memories = []
341
+ self.selected_memory = ""
342
+ return
338
343
  self.memories = [MemoryEntry(**entry)
339
344
  for entry in SERVICE.list_memories()]
340
345
 
@@ -605,6 +610,7 @@ class AdminState(rx.State):
605
610
 
606
611
 
607
612
  ACCENT = "var(--codee-accent)"
613
+ ACCENT_DEEP = "var(--codee-accent-deep)"
608
614
  BORDER = "1px solid var(--codee-border)"
609
615
  MUTED = "var(--codee-muted)"
610
616
  SURFACE = "var(--codee-surface)"
@@ -706,23 +712,26 @@ def shell(content: rx.Component) -> rx.Component:
706
712
  color=TEXT,
707
713
  font_family="IBM Plex Sans, sans-serif",
708
714
  style={
709
- "--codee-accent": rx.color_mode_cond("#167d5a", "#4abd85"),
710
- "--codee-border": rx.color_mode_cond("#dfe4df", "#354039"),
711
- "--codee-muted": rx.color_mode_cond("#68716b", "#a1ada5"),
712
- "--codee-surface": rx.color_mode_cond("#ffffff", "#18201c"),
713
- "--codee-page-background": rx.color_mode_cond("#f3f5f3", "#101512"),
714
- "--codee-nav-background": rx.color_mode_cond("#f7f9f7", "#141b17"),
715
- "--codee-text": rx.color_mode_cond("#202721", "#edf3ef"),
716
- "--codee-hover": rx.color_mode_cond("#edf5f0", "#213029"),
717
- "--codee-active": rx.color_mode_cond("#e2efe9", "#23372e"),
718
- "--codee-grid": rx.color_mode_cond("#e4e9e5", "#2e3932"),
719
- "--codee-subtle-icon": rx.color_mode_cond("#91a097", "#718078"),
720
- "--codee-warning-background": rx.color_mode_cond("#fffbeb", "#332a13"),
721
- "--codee-warning-border": rx.color_mode_cond("#d97706", "#f59e0b"),
715
+ "--codee-accent": rx.color_mode_cond("#2f6fd6", "#7aa9f0"),
716
+ # The darker half of the two-blue brand pairing, for filled marks
717
+ # that should recede behind the interactive blue (step markers).
718
+ "--codee-accent-deep": rx.color_mode_cond("#232c9b", "#5866de"),
719
+ "--codee-border": rx.color_mode_cond("#dbe1ec", "#2b3350"),
720
+ "--codee-muted": rx.color_mode_cond("#5d687f", "#a3adc4"),
721
+ "--codee-surface": rx.color_mode_cond("#ffffff", "#171d33"),
722
+ "--codee-page-background": rx.color_mode_cond("#f2f5fb", "#0e1224"),
723
+ "--codee-nav-background": rx.color_mode_cond("#f7f9fd", "#121729"),
724
+ "--codee-text": rx.color_mode_cond("#1b2440", "#e9edf7"),
725
+ "--codee-hover": rx.color_mode_cond("#edf3fc", "#1e2742"),
726
+ "--codee-active": rx.color_mode_cond("#e0eafb", "#24304f"),
727
+ "--codee-grid": rx.color_mode_cond("#e2e7f1", "#2a3350"),
728
+ "--codee-subtle-icon": rx.color_mode_cond("#8e99b0", "#6e7a94"),
729
+ "--codee-warning-background": rx.color_mode_cond("#fff5ed", "#33200f"),
730
+ "--codee-warning-border": rx.color_mode_cond("#e26128", "#f5854a"),
722
731
  # Tint reserved for in-flight work, so a live run reads at a glance.
723
- "--codee-running-background": rx.color_mode_cond("#effaf4", "#15271f"),
732
+ "--codee-running-background": rx.color_mode_cond("#eff4fd", "#14203a"),
724
733
  "--codee-running-glow": rx.color_mode_cond(
725
- "rgba(22, 125, 90, 0.16)", "rgba(74, 189, 133, 0.18)"),
734
+ "rgba(47, 111, 214, 0.16)", "rgba(122, 169, 240, 0.18)"),
726
735
  "color_scheme": "light dark",
727
736
  },
728
737
  )
@@ -888,13 +897,13 @@ def skill_card(skill: SkillSummary) -> rx.Component:
888
897
  return rx.box(
889
898
  rx.vstack(
890
899
  rx.hstack(rx.heading(skill.name, size="4"), rx.spacer(),
891
- rx.badge(skill.type, color_scheme="green", variant="soft"), width="100%"),
900
+ rx.badge(skill.type, color_scheme="blue", variant="soft"), width="100%"),
892
901
  rx.text(skill.description, color=MUTED, font_size="0.9rem", min_height="2.7rem",
893
902
  overflow="hidden"),
894
903
  rx.cond(
895
904
  skill.issue_status != "",
896
905
  rx.hstack(
897
- rx.badge(skill.issue_type, color_scheme="green",
906
+ rx.badge(skill.issue_type, color_scheme="blue",
898
907
  variant="outline"),
899
908
  rx.icon("circle-dot", size=14, color=SUBTLE_ICON),
900
909
  rx.text(skill.issue_status, color=MUTED,
@@ -1193,8 +1202,12 @@ def memory_page() -> rx.Component:
1193
1202
  memory_row), spacing="3", width="100%"),
1194
1203
  empty_state("notebook-text", "No memories yet."))
1195
1204
  return shell(rx.vstack(page_header("Memory", "Review and maintain durable project context."),
1196
- rx.cond(AdminState.selected_memory ==
1197
- "", listing, memory_editor()),
1205
+ rx.cond(AdminState.coding_agent == "github_copilot",
1206
+ empty_state(
1207
+ "notebook-text",
1208
+ "Github Copilot currently does not support local memory"),
1209
+ rx.cond(AdminState.selected_memory ==
1210
+ "", listing, memory_editor())),
1198
1211
  align="start", width="100%"))
1199
1212
 
1200
1213
 
@@ -1239,7 +1252,7 @@ def workflow_warning(message: rx.Var) -> rx.Component:
1239
1252
  return rx.callout(
1240
1253
  message,
1241
1254
  icon="triangle-alert",
1242
- color_scheme="amber",
1255
+ color_scheme="orange",
1243
1256
  width="100%",
1244
1257
  )
1245
1258
 
@@ -1393,7 +1406,7 @@ def sessions_page() -> rx.Component:
1393
1406
 
1394
1407
  def azure_step(number: int, title: str, detail: rx.Component | str) -> rx.Component:
1395
1408
  return rx.hstack(
1396
- rx.box(str(number), color="white", background=ACCENT, min_width="1.4rem",
1409
+ rx.box(str(number), color="white", background=ACCENT_DEEP, min_width="1.4rem",
1397
1410
  height="1.4rem", display="grid", place_items="center",
1398
1411
  border_radius="50%", font_size="0.75rem", font_weight="700"),
1399
1412
  rx.vstack(rx.text(title, font_weight="600", font_size="0.9rem"),
@@ -1635,10 +1648,10 @@ app = rx.App(
1635
1648
  "width": "6px",
1636
1649
  },
1637
1650
  ".workflow-route-node--forward .react-flow__handle": {
1638
- "background": "#167d5a",
1651
+ "background": "#2f6fd6",
1639
1652
  },
1640
1653
  ".workflow-route-node--return .react-flow__handle": {
1641
- "background": "#d97706",
1654
+ "background": "#e26128",
1642
1655
  },
1643
1656
  },
1644
1657
  stylesheets=[
@@ -22,8 +22,8 @@ config = rx.Config(
22
22
  plugins=[
23
23
  rx.plugins.RadixThemesPlugin(
24
24
  theme=rx.theme(
25
- accent_color="green",
26
- gray_color="sage",
25
+ accent_color="indigo",
26
+ gray_color="slate",
27
27
  radius="small",
28
28
  appearance="inherit",
29
29
  )
@@ -1,4 +1,5 @@
1
1
  """Framework-independent operations for the Codee admin UI."""
2
+ import hashlib
2
3
  import json
3
4
  import re
4
5
  import os
@@ -74,6 +75,9 @@ _CODING_AGENTS: dict[CodingAgent, type[AbstractCodingAgent]] = {
74
75
  }
75
76
  WORKFLOW_NODE_SPACING = 440
76
77
  WORKFLOW_NODE_CENTER_OFFSET = 110
78
+ # Inferring the workflow costs a coding-agent run, so the graph is kept in the
79
+ # data directory and reused by later admin processes.
80
+ WORKFLOW_CACHE_FILE = "workflow.json"
77
81
 
78
82
  # Port the admin UI listens on unless ``codee-admin --port`` says otherwise.
79
83
  # The OAuth redirect URI is built from it, and Entra ID matches redirect URIs
@@ -199,6 +203,18 @@ def _transition_values(value: Any) -> list[dict[str, str]]:
199
203
  return transitions
200
204
 
201
205
 
206
+ def _is_workflow(value: Any) -> bool:
207
+ """Whether a stored graph still has the shape the workflow page reads."""
208
+ if not isinstance(value, dict):
209
+ return False
210
+ return all(
211
+ isinstance(value.get(issue_type), dict)
212
+ and all(isinstance(value[issue_type].get(key), list)
213
+ for key in ("nodes", "edges", "warnings"))
214
+ for issue_type in ISSUE_TYPES
215
+ )
216
+
217
+
202
218
  def _remove_redundant_skill_transitions(
203
219
  transitions: list[dict[str, str]],
204
220
  ) -> list[dict[str, str]]:
@@ -247,7 +263,8 @@ class AdminService:
247
263
  self.data_dir.mkdir(parents=True, exist_ok=True)
248
264
  self.context = CodeeMainContext(data_dir=self.data_dir)
249
265
  self.context.settings = load_settings(self.data_dir)
250
- self._workflow_cache: dict[str, Any] | None = None
266
+ # Cached graph plus the skill fingerprint it was generated from.
267
+ self._workflow_cache: tuple[str, dict[str, Any]] | None = None
251
268
  self._workflow_lock = threading.Lock()
252
269
  # Asking an agent for its catalog can mean spawning its CLI, so the
253
270
  # answer is cached per agent for the life of the process.
@@ -343,16 +360,73 @@ class AdminService:
343
360
  return ""
344
361
 
345
362
  def generate_workflow(self, force: bool = False) -> dict[str, Any]:
346
- """Return cached workflows by issue type, regenerating when requested."""
363
+ """Return cached workflows by issue type, regenerating when requested.
364
+
365
+ The cache outlives the admin process: it is stored in the data
366
+ directory under the fingerprint of the skill documents the graph was
367
+ inferred from, so a restart reuses it while an edited skill still
368
+ regenerates it.
369
+ """
347
370
  workflow_lock = getattr(self, "_workflow_lock", None)
348
371
  if workflow_lock is None:
349
372
  workflow_lock = self._workflow_lock = threading.Lock()
350
373
  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
374
+ fingerprint = self._workflow_fingerprint()
375
+ if not force:
376
+ cached = self._cached_workflow(fingerprint)
377
+ if cached is not None:
378
+ return cached
379
+ workflow = self._generate_workflow()
380
+ self._workflow_cache = (fingerprint, workflow)
381
+ self._store_workflow(fingerprint, workflow)
382
+ return workflow
383
+
384
+ def _workflow_fingerprint(self) -> str:
385
+ """Digest the skill documents the workflow graph is inferred from."""
386
+ digest = hashlib.sha256()
387
+ for skill in find_issue_triggered_skills(self.skills_dir):
388
+ digest.update(skill.slug.encode())
389
+ digest.update(b"\0")
390
+ digest.update(skill.path.read_bytes())
391
+ digest.update(b"\0")
392
+ return digest.hexdigest()
393
+
394
+ def _workflow_cache_path(self) -> Path:
395
+ return self.data_dir / WORKFLOW_CACHE_FILE
396
+
397
+ def _cached_workflow(self, fingerprint: str) -> dict[str, Any] | None:
398
+ """This process's graph, else the one an earlier process stored."""
399
+ cached = getattr(self, "_workflow_cache", None)
400
+ if cached is not None and cached[0] == fingerprint:
401
+ return cached[1]
402
+ try:
403
+ stored = json.loads(self._workflow_cache_path().read_text())
404
+ except FileNotFoundError:
405
+ return None
406
+ except (OSError, ValueError) as error:
407
+ print(f"[admin] Ignoring unreadable workflow cache: {error}")
408
+ return None
409
+ if not isinstance(stored, dict) or stored.get("fingerprint") != fingerprint:
410
+ return None
411
+ workflow = stored.get("workflow")
412
+ if not _is_workflow(workflow):
413
+ return None
414
+ self._workflow_cache = (fingerprint, workflow)
415
+ return workflow
416
+
417
+ def _store_workflow(self, fingerprint: str, workflow: dict[str, Any]) -> None:
418
+ """Persist the graph for the next admin process; failing is not fatal."""
419
+ path = self._workflow_cache_path()
420
+ pending = path.with_suffix(".tmp")
421
+ try:
422
+ path.parent.mkdir(parents=True, exist_ok=True)
423
+ pending.write_text(json.dumps(
424
+ {"fingerprint": fingerprint, "workflow": workflow}))
425
+ # Replace in one step so a crash mid-write cannot leave a
426
+ # half-written cache behind.
427
+ pending.replace(path)
428
+ except OSError as error:
429
+ print(f"[admin] Failed to store workflow cache: {error}")
356
430
 
357
431
  def _generate_workflow(self) -> dict[str, Any]:
358
432
  """Infer separate status graphs for story and task skills."""
@@ -15,6 +15,25 @@ from codee_main_context.context import (
15
15
  CodeeMainContext, CodingAgent, Settings, TasksProvider, save_settings)
16
16
 
17
17
 
18
+ def _empty_workflow() -> dict:
19
+ return {issue_type: {"nodes": [], "edges": [], "warnings": []}
20
+ for issue_type in ("story", "task")}
21
+
22
+
23
+ def _write_issue_skill(root: Path) -> Path:
24
+ """Create one issue-trigger skill, the input the workflow cache is keyed on."""
25
+ skills_dir = root / ".claude" / "skills"
26
+ skill_dir = skills_dir / "develop"
27
+ skill_dir.mkdir(parents=True)
28
+ (skill_dir / "SKILL.md").write_text(
29
+ "---\nname: develop\ndisable-model-invocation: true\n"
30
+ "x-codee-trigger: issue\nx-codee-issue-status: [Ready]\n"
31
+ "x-codee-issue-type: story\n---\n"
32
+ "After implementation, move the issue to Review.\n"
33
+ )
34
+ return skills_dir
35
+
36
+
18
37
  class AdminServiceIssueTriggerTest(unittest.TestCase):
19
38
  def test_list_skills_includes_issue_status(self) -> None:
20
39
  with tempfile.TemporaryDirectory() as temporary_directory:
@@ -227,6 +246,7 @@ class AdminServiceIssueTriggerTest(unittest.TestCase):
227
246
  service = AdminService.__new__(AdminService)
228
247
  service.root = root
229
248
  service.skills_dir = skills_dir
249
+ service.data_dir = root / ".codee"
230
250
  service.context = Mock(settings=Settings(
231
251
  coding_agent=CodingAgent.CLAUDE_CODE))
232
252
 
@@ -315,6 +335,7 @@ class AdminServiceIssueTriggerTest(unittest.TestCase):
315
335
  service = AdminService.__new__(AdminService)
316
336
  service.root = root
317
337
  service.skills_dir = skills_dir
338
+ service.data_dir = root / ".codee"
318
339
  service.context = Mock(
319
340
  settings=Settings(coding_agent=CodingAgent.CLAUDE_CODE))
320
341
 
@@ -356,6 +377,7 @@ class AdminServiceIssueTriggerTest(unittest.TestCase):
356
377
  service = AdminService.__new__(AdminService)
357
378
  service.root = root
358
379
  service.skills_dir = skills_dir
380
+ service.data_dir = root / ".codee"
359
381
  service.context = Mock(
360
382
  settings=Settings(coding_agent=CodingAgent.CLAUDE_CODE))
361
383
 
@@ -406,6 +428,7 @@ class AdminServiceIssueTriggerTest(unittest.TestCase):
406
428
  service = AdminService.__new__(AdminService)
407
429
  service.root = root
408
430
  service.skills_dir = skills_dir
431
+ service.data_dir = root / ".codee"
409
432
  service.context = Mock(
410
433
  settings=Settings(coding_agent=CodingAgent.CLAUDE_CODE))
411
434
 
@@ -462,6 +485,7 @@ class AdminServiceIssueTriggerTest(unittest.TestCase):
462
485
  service = AdminService.__new__(AdminService)
463
486
  service.root = root
464
487
  service.skills_dir = skills_dir
488
+ service.data_dir = root / ".codee"
465
489
  service.context = Mock(
466
490
  settings=Settings(coding_agent=CodingAgent.CLAUDE_CODE))
467
491
 
@@ -501,6 +525,7 @@ class AdminServiceIssueTriggerTest(unittest.TestCase):
501
525
  service = AdminService.__new__(AdminService)
502
526
  service.root = root
503
527
  service.skills_dir = skills_dir
528
+ service.data_dir = root / ".codee"
504
529
  service.context = Mock(
505
530
  settings=Settings(coding_agent=CodingAgent.CLAUDE_CODE))
506
531
 
@@ -518,8 +543,10 @@ class AdminServiceIssueTriggerTest(unittest.TestCase):
518
543
 
519
544
  def test_generate_workflow_returns_empty_without_issue_skills(self) -> None:
520
545
  with tempfile.TemporaryDirectory() as temporary_directory:
546
+ root = Path(temporary_directory)
521
547
  service = AdminService.__new__(AdminService)
522
- service.skills_dir = Path(temporary_directory)
548
+ service.skills_dir = root
549
+ service.data_dir = root / ".codee"
523
550
 
524
551
  self.assertEqual(service.generate_workflow(), {
525
552
  "story": {"nodes": [], "edges": [], "warnings": []},
@@ -527,15 +554,92 @@ class AdminServiceIssueTriggerTest(unittest.TestCase):
527
554
  })
528
555
 
529
556
  def test_generate_workflow_is_cached_until_forced(self) -> None:
530
- service = AdminService.__new__(AdminService)
531
- generated = {"nodes": [], "edges": [], "warnings": []}
557
+ with tempfile.TemporaryDirectory() as temporary_directory:
558
+ root = Path(temporary_directory)
559
+ service = AdminService.__new__(AdminService)
560
+ service.skills_dir = root / ".claude" / "skills"
561
+ service.data_dir = root / ".codee"
562
+ generated = _empty_workflow()
563
+
564
+ with patch.object(service, "_generate_workflow",
565
+ return_value=generated) as generate:
566
+ self.assertIs(service.generate_workflow(), generated)
567
+ self.assertIs(service.generate_workflow(), generated)
568
+ self.assertIs(service.generate_workflow(force=True), generated)
569
+
570
+ self.assertEqual(generate.call_count, 2)
571
+
572
+ def test_generate_workflow_reuses_graph_stored_by_an_earlier_process(self) -> None:
573
+ with tempfile.TemporaryDirectory() as temporary_directory:
574
+ root = Path(temporary_directory)
575
+ skills_dir = _write_issue_skill(root)
576
+ generated = _empty_workflow()
577
+ generated["story"]["warnings"] = ["stored"]
578
+
579
+ first = AdminService.__new__(AdminService)
580
+ first.skills_dir = skills_dir
581
+ first.data_dir = root / ".codee"
582
+ with patch.object(first, "_generate_workflow", return_value=generated):
583
+ first.generate_workflow()
584
+
585
+ # A restart starts from an empty in-memory cache, so only the file
586
+ # written above can spare it another coding-agent run.
587
+ restarted = AdminService.__new__(AdminService)
588
+ restarted.skills_dir = skills_dir
589
+ restarted.data_dir = root / ".codee"
590
+ with patch.object(restarted, "_generate_workflow") as generate:
591
+ workflow = restarted.generate_workflow()
532
592
 
533
- with patch.object(service, "_generate_workflow", return_value=generated) as generate:
534
- self.assertIs(service.generate_workflow(), generated)
535
- self.assertIs(service.generate_workflow(), generated)
536
- self.assertIs(service.generate_workflow(force=True), generated)
593
+ generate.assert_not_called()
594
+ self.assertEqual(workflow["story"]["warnings"], ["stored"])
595
+
596
+ def test_generate_workflow_ignores_stored_graph_after_a_skill_changes(self) -> None:
597
+ with tempfile.TemporaryDirectory() as temporary_directory:
598
+ root = Path(temporary_directory)
599
+ skills_dir = _write_issue_skill(root)
600
+
601
+ first = AdminService.__new__(AdminService)
602
+ first.skills_dir = skills_dir
603
+ first.data_dir = root / ".codee"
604
+ with patch.object(first, "_generate_workflow",
605
+ return_value=_empty_workflow()):
606
+ first.generate_workflow()
607
+
608
+ (skills_dir / "develop" / "SKILL.md").write_text(
609
+ "---\nname: develop\ndisable-model-invocation: true\n"
610
+ "x-codee-trigger: issue\nx-codee-issue-status: [Ready]\n"
611
+ "x-codee-issue-type: story\n---\n"
612
+ "After implementation, move the issue to Done.\n"
613
+ )
614
+ regenerated = _empty_workflow()
615
+ regenerated["story"]["warnings"] = ["regenerated"]
616
+ restarted = AdminService.__new__(AdminService)
617
+ restarted.skills_dir = skills_dir
618
+ restarted.data_dir = root / ".codee"
619
+ with patch.object(restarted, "_generate_workflow",
620
+ return_value=regenerated) as generate:
621
+ workflow = restarted.generate_workflow()
622
+
623
+ generate.assert_called_once_with()
624
+ self.assertEqual(workflow["story"]["warnings"], ["regenerated"])
625
+
626
+ def test_generate_workflow_ignores_a_corrupt_stored_graph(self) -> None:
627
+ with tempfile.TemporaryDirectory() as temporary_directory:
628
+ root = Path(temporary_directory)
629
+ skills_dir = _write_issue_skill(root)
630
+ data_dir = root / ".codee"
631
+ data_dir.mkdir()
632
+ (data_dir / "workflow.json").write_text("{ not json")
633
+
634
+ service = AdminService.__new__(AdminService)
635
+ service.skills_dir = skills_dir
636
+ service.data_dir = data_dir
637
+ generated = _empty_workflow()
638
+ with patch.object(service, "_generate_workflow",
639
+ return_value=generated) as generate:
640
+ self.assertIs(service.generate_workflow(), generated)
537
641
 
538
- self.assertEqual(generate.call_count, 2)
642
+ generate.assert_called_once_with()
539
643
 
540
644
 
541
645
  class AdminServiceSkillModelTest(unittest.TestCase):
File without changes
File without changes