hexdag-forge 0.2.0.dev3__tar.gz → 0.2.0.dev5__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 (80) hide show
  1. {hexdag_forge-0.2.0.dev3 → hexdag_forge-0.2.0.dev5}/PKG-INFO +1 -1
  2. {hexdag_forge-0.2.0.dev3 → hexdag_forge-0.2.0.dev5}/hexdag_forge/cli.py +32 -0
  3. {hexdag_forge-0.2.0.dev3 → hexdag_forge-0.2.0.dev5}/hexdag_forge/domain/business_profile.py +1 -0
  4. {hexdag_forge-0.2.0.dev3 → hexdag_forge-0.2.0.dev5}/hexdag_forge/generator/assembler.py +49 -16
  5. {hexdag_forge-0.2.0.dev3 → hexdag_forge-0.2.0.dev5}/hexdag_forge/generator/django_generator.py +37 -2
  6. {hexdag_forge-0.2.0.dev3 → hexdag_forge-0.2.0.dev5}/hexdag_forge/generator/migration_generator.py +45 -0
  7. {hexdag_forge-0.2.0.dev3 → hexdag_forge-0.2.0.dev5}/hexdag_forge/generator/pipeline_generator.py +4 -0
  8. {hexdag_forge-0.2.0.dev3 → hexdag_forge-0.2.0.dev5}/hexdag_forge/generator/system_generator.py +12 -2
  9. hexdag_forge-0.2.0.dev5/hexdag_forge/generator/template_engine.py +209 -0
  10. {hexdag_forge-0.2.0.dev3 → hexdag_forge-0.2.0.dev5}/hexdag_forge/mcp/server.py +1 -1
  11. hexdag_forge-0.2.0.dev5/hexdag_forge/runtime/__init__.py +1 -0
  12. hexdag_forge-0.2.0.dev5/hexdag_forge/runtime/django_store.py +124 -0
  13. hexdag_forge-0.2.0.dev5/hexdag_forge/runtime/entity_store.py +59 -0
  14. hexdag_forge-0.2.0.dev5/hexdag_forge/runtime/memory_store.py +95 -0
  15. hexdag_forge-0.2.0.dev5/hexdag_forge/templates/chat_pipeline.yaml.j2 +39 -0
  16. hexdag_forge-0.2.0.dev5/hexdag_forge/templates/django_base.html.j2 +153 -0
  17. hexdag_forge-0.2.0.dev5/hexdag_forge/templates/django_chat.html.j2 +119 -0
  18. hexdag_forge-0.2.0.dev5/hexdag_forge/templates/django_chat_agent.py.j2 +97 -0
  19. hexdag_forge-0.2.0.dev5/hexdag_forge/templates/django_chat_conversations.html.j2 +27 -0
  20. hexdag_forge-0.2.0.dev5/hexdag_forge/templates/django_chat_messages_partial.html.j2 +10 -0
  21. hexdag_forge-0.2.0.dev5/hexdag_forge/templates/django_dashboard.html.j2 +47 -0
  22. hexdag_forge-0.2.0.dev5/hexdag_forge/templates/django_entity_detail_partial.html.j2 +43 -0
  23. {hexdag_forge-0.2.0.dev3 → hexdag_forge-0.2.0.dev5}/hexdag_forge/templates/django_entity_form.html.j2 +5 -5
  24. hexdag_forge-0.2.0.dev5/hexdag_forge/templates/django_entity_form_modal.html.j2 +13 -0
  25. {hexdag_forge-0.2.0.dev3 → hexdag_forge-0.2.0.dev5}/hexdag_forge/templates/django_entity_list.html.j2 +13 -7
  26. hexdag_forge-0.2.0.dev5/hexdag_forge/templates/django_entity_list_partial.html.j2 +15 -0
  27. {hexdag_forge-0.2.0.dev3 → hexdag_forge-0.2.0.dev5}/hexdag_forge/templates/django_settings.py.j2 +6 -0
  28. hexdag_forge-0.2.0.dev5/hexdag_forge/templates/django_templatetags.py.j2 +43 -0
  29. {hexdag_forge-0.2.0.dev3 → hexdag_forge-0.2.0.dev5}/hexdag_forge/templates/django_urls.py.j2 +3 -2
  30. {hexdag_forge-0.2.0.dev3 → hexdag_forge-0.2.0.dev5}/hexdag_forge/templates/django_views.py.j2 +58 -3
  31. {hexdag_forge-0.2.0.dev3 → hexdag_forge-0.2.0.dev5}/hexdag_forge/templates/pipeline.yaml.j2 +6 -10
  32. {hexdag_forge-0.2.0.dev3 → hexdag_forge-0.2.0.dev5}/hexdag_forge/templates/system.yaml.j2 +9 -1
  33. {hexdag_forge-0.2.0.dev3 → hexdag_forge-0.2.0.dev5}/pyproject.toml +1 -1
  34. {hexdag_forge-0.2.0.dev3 → hexdag_forge-0.2.0.dev5}/tests/test_generator/test_assembler.py +206 -5
  35. hexdag_forge-0.2.0.dev5/tests/test_runtime/__init__.py +0 -0
  36. hexdag_forge-0.2.0.dev5/tests/test_runtime/test_memory_store.py +129 -0
  37. hexdag_forge-0.2.0.dev3/hexdag_forge/generator/template_engine.py +0 -64
  38. hexdag_forge-0.2.0.dev3/hexdag_forge/templates/django_base.html.j2 +0 -50
  39. hexdag_forge-0.2.0.dev3/hexdag_forge/templates/django_chat.html.j2 +0 -43
  40. hexdag_forge-0.2.0.dev3/hexdag_forge/templates/django_chat_conversations.html.j2 +0 -29
  41. hexdag_forge-0.2.0.dev3/hexdag_forge/templates/django_chat_messages_partial.html.j2 +0 -8
  42. hexdag_forge-0.2.0.dev3/hexdag_forge/templates/django_entity_detail_partial.html.j2 +0 -29
  43. hexdag_forge-0.2.0.dev3/hexdag_forge/templates/django_entity_list_partial.html.j2 +0 -11
  44. {hexdag_forge-0.2.0.dev3 → hexdag_forge-0.2.0.dev5}/.gitignore +0 -0
  45. {hexdag_forge-0.2.0.dev3 → hexdag_forge-0.2.0.dev5}/LICENSE +0 -0
  46. {hexdag_forge-0.2.0.dev3 → hexdag_forge-0.2.0.dev5}/README.md +0 -0
  47. {hexdag_forge-0.2.0.dev3 → hexdag_forge-0.2.0.dev5}/hexdag_forge/__init__.py +0 -0
  48. {hexdag_forge-0.2.0.dev3 → hexdag_forge-0.2.0.dev5}/hexdag_forge/domain/__init__.py +0 -0
  49. {hexdag_forge-0.2.0.dev3 → hexdag_forge-0.2.0.dev5}/hexdag_forge/domain/enums.py +0 -0
  50. {hexdag_forge-0.2.0.dev3 → hexdag_forge-0.2.0.dev5}/hexdag_forge/domain/generated_system.py +0 -0
  51. {hexdag_forge-0.2.0.dev3 → hexdag_forge-0.2.0.dev5}/hexdag_forge/domain/profile_diff.py +0 -0
  52. {hexdag_forge-0.2.0.dev3 → hexdag_forge-0.2.0.dev5}/hexdag_forge/generator/__init__.py +0 -0
  53. {hexdag_forge-0.2.0.dev3 → hexdag_forge-0.2.0.dev5}/hexdag_forge/generator/diff.py +0 -0
  54. {hexdag_forge-0.2.0.dev3 → hexdag_forge-0.2.0.dev5}/hexdag_forge/generator/entity_generator.py +0 -0
  55. {hexdag_forge-0.2.0.dev3 → hexdag_forge-0.2.0.dev5}/hexdag_forge/generator/field_mapping.py +0 -0
  56. {hexdag_forge-0.2.0.dev3 → hexdag_forge-0.2.0.dev5}/hexdag_forge/generator/service_generator.py +0 -0
  57. {hexdag_forge-0.2.0.dev3 → hexdag_forge-0.2.0.dev5}/hexdag_forge/mcp/__init__.py +0 -0
  58. {hexdag_forge-0.2.0.dev3 → hexdag_forge-0.2.0.dev5}/hexdag_forge/mcp/__main__.py +0 -0
  59. {hexdag_forge-0.2.0.dev3 → hexdag_forge-0.2.0.dev5}/hexdag_forge/skills/forge.md +0 -0
  60. {hexdag_forge-0.2.0.dev3 → hexdag_forge-0.2.0.dev5}/hexdag_forge/skills/forge_entity.md +0 -0
  61. {hexdag_forge-0.2.0.dev3 → hexdag_forge-0.2.0.dev5}/hexdag_forge/skills/forge_implement.md +0 -0
  62. {hexdag_forge-0.2.0.dev3 → hexdag_forge-0.2.0.dev5}/hexdag_forge/skills/forge_test.md +0 -0
  63. {hexdag_forge-0.2.0.dev3 → hexdag_forge-0.2.0.dev5}/hexdag_forge/templates/django_admin.py.j2 +0 -0
  64. {hexdag_forge-0.2.0.dev3 → hexdag_forge-0.2.0.dev5}/hexdag_forge/templates/django_apps.py.j2 +0 -0
  65. {hexdag_forge-0.2.0.dev3 → hexdag_forge-0.2.0.dev5}/hexdag_forge/templates/django_entity_confirm_delete.html.j2 +0 -0
  66. {hexdag_forge-0.2.0.dev3 → hexdag_forge-0.2.0.dev5}/hexdag_forge/templates/django_entity_detail.html.j2 +0 -0
  67. {hexdag_forge-0.2.0.dev3 → hexdag_forge-0.2.0.dev5}/hexdag_forge/templates/django_forms.py.j2 +0 -0
  68. {hexdag_forge-0.2.0.dev3 → hexdag_forge-0.2.0.dev5}/hexdag_forge/templates/django_logged_out.html.j2 +0 -0
  69. {hexdag_forge-0.2.0.dev3 → hexdag_forge-0.2.0.dev5}/hexdag_forge/templates/django_login.html.j2 +0 -0
  70. {hexdag_forge-0.2.0.dev3 → hexdag_forge-0.2.0.dev5}/hexdag_forge/templates/django_manage.py.j2 +0 -0
  71. {hexdag_forge-0.2.0.dev3 → hexdag_forge-0.2.0.dev5}/hexdag_forge/templates/django_migration.py.j2 +0 -0
  72. {hexdag_forge-0.2.0.dev3 → hexdag_forge-0.2.0.dev5}/hexdag_forge/templates/django_model.py.j2 +0 -0
  73. {hexdag_forge-0.2.0.dev3 → hexdag_forge-0.2.0.dev5}/hexdag_forge/templates/django_register.html.j2 +0 -0
  74. {hexdag_forge-0.2.0.dev3 → hexdag_forge-0.2.0.dev5}/hexdag_forge/templates/service.py.j2 +0 -0
  75. {hexdag_forge-0.2.0.dev3 → hexdag_forge-0.2.0.dev5}/hexdag_forge/templates/state_machines.py.j2 +0 -0
  76. {hexdag_forge-0.2.0.dev3 → hexdag_forge-0.2.0.dev5}/tests/__init__.py +0 -0
  77. {hexdag_forge-0.2.0.dev3 → hexdag_forge-0.2.0.dev5}/tests/test_domain.py +0 -0
  78. {hexdag_forge-0.2.0.dev3 → hexdag_forge-0.2.0.dev5}/tests/test_generator/__init__.py +0 -0
  79. {hexdag_forge-0.2.0.dev3 → hexdag_forge-0.2.0.dev5}/tests/test_generator/test_diff.py +0 -0
  80. {hexdag_forge-0.2.0.dev3 → hexdag_forge-0.2.0.dev5}/tests/test_generator/test_migration_generator.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hexdag-forge
3
- Version: 0.2.0.dev3
3
+ Version: 0.2.0.dev5
4
4
  Summary: Generate operational systems from prompts — code generator powered by hexDAG
5
5
  Project-URL: Homepage, https://hexdag.ai
6
6
  Project-URL: Repository, https://github.com/omniviser/hexdag
@@ -51,6 +51,38 @@ def validate(
51
51
  typer.echo("All generated files are valid.")
52
52
 
53
53
 
54
+ @app.command()
55
+ def run(
56
+ output: Annotated[
57
+ Path, typer.Option(help="Path to the generated system directory")
58
+ ] = "./generated",
59
+ ) -> None:
60
+ """Load and run a generated system via hexDAG System API."""
61
+ import asyncio
62
+
63
+ system_path = output / "system.yaml"
64
+ if not system_path.exists():
65
+ typer.echo(
66
+ f"No system.yaml found in {output}/ — run 'hexdag-forge generate' first.",
67
+ err=True,
68
+ )
69
+ raise typer.Exit(1)
70
+
71
+ async def _run() -> None:
72
+ from hexdag.kernel import System
73
+
74
+ typer.echo(f"Loading system from {system_path} ...")
75
+ async with System.from_yaml(str(system_path)) as system:
76
+ typer.echo(f"System '{system.config.metadata.get('name', '?')}' ready.")
77
+ typer.echo(f" Mode: {'lifecycle' if system.is_lifecycle else 'DAG'}")
78
+ typer.echo(f" Processes: {', '.join(system.process_names)}")
79
+ typer.echo("System loaded successfully. Use the Python API to interact:")
80
+ typer.echo(" await system.transition(entity_type, entity_id, to_state)")
81
+ typer.echo(" await system.run_process(process_name, input_data)")
82
+
83
+ asyncio.run(_run())
84
+
85
+
54
86
  @mcp_app.command("serve")
55
87
  def mcp_serve(
56
88
  transport: Annotated[str, typer.Option(help="Transport: stdio or sse")] = "stdio",
@@ -100,6 +100,7 @@ class BusinessProfile(BaseModel):
100
100
  workflows: list[str] = []
101
101
  modules: list[str] = []
102
102
  app_label: str = ""
103
+ output_module: str = ""
103
104
 
104
105
  @model_validator(mode="before")
105
106
  @classmethod
@@ -18,22 +18,44 @@ from hexdag_forge.generator.service_generator import generate_services
18
18
  from hexdag_forge.generator.system_generator import generate_system
19
19
 
20
20
 
21
- def assemble(profile: BusinessProfile, output_dir: Path) -> GeneratedSystem:
21
+ def _derive_output_module(output_dir: Path) -> str:
22
+ """Derive a Python module name from the output directory.
23
+
24
+ ``Path("./generated")`` → ``"generated"``
25
+ ``Path("/home/user/my_app")`` → ``"my_app"``
26
+ """
27
+ name = output_dir.resolve().name
28
+ # Sanitise to valid Python identifier
29
+ import re
30
+
31
+ name = re.sub(r"[^a-zA-Z0-9_]", "_", name).strip("_").lower()
32
+ return name or "generated"
33
+
34
+
35
+ def assemble(
36
+ profile: BusinessProfile,
37
+ output_dir: Path,
38
+ *,
39
+ skip_migration: bool = False,
40
+ ) -> GeneratedSystem:
22
41
  """Generate all files from a BusinessProfile and write to output_dir.
23
42
 
24
43
  Args:
25
44
  profile: The business profile to generate from.
26
45
  output_dir: Directory to write generated files to.
46
+ skip_migration: If True, skip migration generation (used when the
47
+ caller has already generated the migration, e.g. ``forge_migrate``).
27
48
 
28
49
  Returns:
29
50
  GeneratedSystem with metadata about what was generated.
30
51
  """
31
52
  output_dir.mkdir(parents=True, exist_ok=True)
53
+ output_module = profile.output_module or _derive_output_module(output_dir)
32
54
 
33
55
  # Generate all artifacts
34
56
  pipelines = generate_pipelines(profile)
35
57
  services = generate_services(profile)
36
- system_yaml = generate_system(profile)
58
+ system_yaml = generate_system(profile, output_module=output_module)
37
59
  django_files = generate_django(profile)
38
60
 
39
61
  # Write pipelines
@@ -42,9 +64,10 @@ def assemble(profile: BusinessProfile, output_dir: Path) -> GeneratedSystem:
42
64
  for filename, content in pipelines.items():
43
65
  (pipelines_dir / filename).write_text(content)
44
66
 
45
- # Write services
67
+ # Write services as a proper Python package
46
68
  services_dir = output_dir / "services"
47
69
  services_dir.mkdir(exist_ok=True)
70
+ (services_dir / "__init__.py").write_text("")
48
71
  for filename, content in services.items():
49
72
  (services_dir / filename).write_text(content)
50
73
 
@@ -67,19 +90,29 @@ def assemble(profile: BusinessProfile, output_dir: Path) -> GeneratedSystem:
67
90
  migrations_dir.mkdir(exist_ok=True)
68
91
  (migrations_dir / "__init__.py").write_text("")
69
92
 
70
- from hexdag_forge.generator.migration_generator import generate_migration
71
-
72
- empty_profile = BusinessProfile(
73
- business_name=profile.business_name,
74
- business_type=profile.business_type,
75
- description=profile.description,
76
- entities=[],
77
- app_label=profile.app_label,
78
- )
79
- result = generate_migration(empty_profile, profile, migrations_dir)
80
- if result is not None:
81
- filename, migration_content = result
82
- (migrations_dir / filename).write_text(migration_content)
93
+ if not skip_migration:
94
+ from hexdag_forge.generator.migration_generator import generate_migration
95
+
96
+ # Use existing profile.json (from a previous run) as the old state so
97
+ # that diff_profiles produces incremental operations (AddField, etc.)
98
+ # instead of CreateModel for every entity. On the first run the file
99
+ # does not exist yet, so we fall back to an empty profile.
100
+ profile_path = output_dir / "profile.json"
101
+ if profile_path.exists():
102
+ old_profile = BusinessProfile.model_validate_json(profile_path.read_text())
103
+ else:
104
+ old_profile = BusinessProfile(
105
+ business_name=profile.business_name,
106
+ business_type=profile.business_type,
107
+ description=profile.description,
108
+ entities=[],
109
+ app_label=profile.app_label,
110
+ )
111
+
112
+ result = generate_migration(old_profile, profile, migrations_dir)
113
+ if result is not None:
114
+ filename, migration_content = result
115
+ (migrations_dir / filename).write_text(migration_content)
83
116
 
84
117
  # Write profile.json for refinement
85
118
  (output_dir / "profile.json").write_text(profile.model_dump_json(indent=2))
@@ -4,7 +4,13 @@ from __future__ import annotations
4
4
 
5
5
  from typing import TYPE_CHECKING
6
6
 
7
- from hexdag_forge.generator.template_engine import render_html_template, render_template
7
+ from hexdag_forge.generator.template_engine import (
8
+ build_badge_map,
9
+ build_fk_fields,
10
+ build_list_columns,
11
+ render_html_template,
12
+ render_template,
13
+ )
8
14
 
9
15
  if TYPE_CHECKING:
10
16
  from hexdag_forge.domain.business_profile import BusinessProfile
@@ -17,8 +23,13 @@ def generate_django(profile: BusinessProfile) -> dict[str, str]:
17
23
  Dict of relative path -> file content.
18
24
  """
19
25
  app = profile.app_label
26
+ badge_colors = build_badge_map(profile)
20
27
  files: dict[str, str] = {}
21
28
 
29
+ # Pre-compute per-entity context
30
+ list_columns_map = {e.name: build_list_columns(e) for e in profile.entities}
31
+ fk_fields_map = {e.name: build_fk_fields(e) for e in profile.entities}
32
+
22
33
  # ── Python files (standard Jinja2 delimiters) ──────────
23
34
  files[f"{app}/models.py"] = render_template("django_model.py.j2", profile=profile)
24
35
  files[f"{app}/apps.py"] = render_template("django_apps.py.j2", profile=profile)
@@ -29,10 +40,24 @@ def generate_django(profile: BusinessProfile) -> dict[str, str]:
29
40
  files["state_machines.py"] = render_template("state_machines.py.j2", profile=profile)
30
41
  files["views.py"] = render_template("django_views.py.j2", profile=profile)
31
42
  files["forms.py"] = render_template("django_forms.py.j2", profile=profile)
43
+ files["chat_agent.py"] = render_template("django_chat_agent.py.j2", profile=profile)
44
+
45
+ # Templatetags
46
+ files[f"{app}/templatetags/__init__.py"] = ""
47
+ files[f"{app}/templatetags/{app}_tags.py"] = render_template(
48
+ "django_templatetags.py.j2", profile=profile, badge_colors=badge_colors
49
+ )
32
50
 
33
51
  # ── HTML templates (alt delimiters — outputs Django template syntax) ──
34
52
  files["templates/base.html"] = render_html_template("django_base.html.j2", profile=profile)
35
53
 
54
+ # Dashboard
55
+ files["templates/dashboard/index.html"] = render_html_template(
56
+ "django_dashboard.html.j2",
57
+ profile=profile,
58
+ list_columns=list_columns_map,
59
+ )
60
+
36
61
  # Auth
37
62
  files["templates/registration/login.html"] = render_html_template(
38
63
  "django_login.html.j2", profile=profile
@@ -47,7 +72,14 @@ def generate_django(profile: BusinessProfile) -> dict[str, str]:
47
72
  # Per-entity CRUD templates
48
73
  for entity in profile.entities:
49
74
  prefix = f"templates/{entity.name}"
50
- ctx = {"entity": entity, "profile": profile}
75
+ list_cols = list_columns_map[entity.name]
76
+ fk_flds = fk_fields_map[entity.name]
77
+ ctx = {
78
+ "entity": entity,
79
+ "profile": profile,
80
+ "list_columns": list_cols,
81
+ "fk_fields": fk_flds,
82
+ }
51
83
  files[f"{prefix}/list.html"] = render_html_template("django_entity_list.html.j2", **ctx)
52
84
  files[f"{prefix}/_list_rows.html"] = render_html_template(
53
85
  "django_entity_list_partial.html.j2", **ctx
@@ -57,6 +89,9 @@ def generate_django(profile: BusinessProfile) -> dict[str, str]:
57
89
  "django_entity_detail_partial.html.j2", **ctx
58
90
  )
59
91
  files[f"{prefix}/form.html"] = render_html_template("django_entity_form.html.j2", **ctx)
92
+ files[f"{prefix}/_form_modal.html"] = render_html_template(
93
+ "django_entity_form_modal.html.j2", **ctx
94
+ )
60
95
  files[f"{prefix}/confirm_delete.html"] = render_html_template(
61
96
  "django_entity_confirm_delete.html.j2", **ctx
62
97
  )
@@ -48,6 +48,11 @@ def generate_migration(
48
48
  number = _next_migration_number(migrations_dir)
49
49
  dependency = _latest_migration_name(migrations_dir)
50
50
  operations = _build_operations(diff)
51
+
52
+ # Include chat infrastructure models in the initial migration
53
+ if number == 1:
54
+ operations.extend(_chat_model_operations())
55
+
51
56
  has_fk = any("ForeignKey" in op for op in operations)
52
57
 
53
58
  migration_name = f"{number:04d}_auto"
@@ -247,3 +252,43 @@ def _render_alter_states(sc: StateChange) -> str:
247
252
  f" ),\n"
248
253
  f" )"
249
254
  )
255
+
256
+
257
+ def _chat_model_operations() -> list[str]:
258
+ """CreateModel operations for the built-in Conversation and Message models."""
259
+ return [
260
+ (
261
+ "migrations.CreateModel(\n"
262
+ ' name="Conversation",\n'
263
+ " fields=[\n"
264
+ ' ("id", models.BigAutoField(auto_created=True, primary_key=True, '
265
+ 'serialize=False, verbose_name="ID")),\n'
266
+ ' ("user", models.ForeignKey('
267
+ "on_delete=django.db.models.deletion.CASCADE, "
268
+ 'related_name="conversations", to="auth.user")),\n'
269
+ ' ("title", models.CharField('
270
+ 'default="New Conversation", max_length=255)),\n'
271
+ ' ("created_at", models.DateTimeField(auto_now_add=True)),\n'
272
+ ' ("updated_at", models.DateTimeField(auto_now=True)),\n'
273
+ " ],\n"
274
+ ' options={"ordering": ["-updated_at"]},\n'
275
+ " )"
276
+ ),
277
+ (
278
+ "migrations.CreateModel(\n"
279
+ ' name="Message",\n'
280
+ " fields=[\n"
281
+ ' ("id", models.BigAutoField(auto_created=True, primary_key=True, '
282
+ 'serialize=False, verbose_name="ID")),\n'
283
+ ' ("conversation", models.ForeignKey('
284
+ "on_delete=django.db.models.deletion.CASCADE, "
285
+ 'related_name="messages", to="conversation")),\n'
286
+ ' ("sender", models.CharField('
287
+ 'choices=[("user", "User"), ("system", "System")], max_length=10)),\n'
288
+ ' ("content", models.TextField()),\n'
289
+ ' ("created_at", models.DateTimeField(auto_now_add=True)),\n'
290
+ " ],\n"
291
+ ' options={"ordering": ["created_at"]},\n'
292
+ " )"
293
+ ),
294
+ ]
@@ -21,4 +21,8 @@ def generate_pipelines(profile: BusinessProfile) -> dict[str, str]:
21
21
  filename = f"{entity.name}_lifecycle.yaml"
22
22
  content = render_template("pipeline.yaml.j2", entity=entity)
23
23
  pipelines[filename] = content
24
+
25
+ # Chat agent pipeline
26
+ pipelines["chat_agent.yaml"] = render_template("chat_pipeline.yaml.j2", profile=profile)
27
+
24
28
  return pipelines
@@ -11,11 +11,21 @@ if TYPE_CHECKING:
11
11
  from hexdag_forge.domain.business_profile import BusinessProfile
12
12
 
13
13
 
14
- def generate_system(profile: BusinessProfile) -> str:
14
+ def generate_system(profile: BusinessProfile, *, output_module: str = "generated") -> str:
15
15
  """Generate the kind: System YAML manifest.
16
16
 
17
17
  Uses LifecycleRunner (because state_machines is declared).
18
18
  Each entity state maps to a process pipeline via on_enter.
19
+
20
+ Args:
21
+ profile: The business profile.
22
+ output_module: Python module name for the generated output directory.
23
+ Used to build absolute import paths for services.
19
24
  """
20
25
  terminal_states = generate_terminal_states_map(profile)
21
- return render_template("system.yaml.j2", profile=profile, terminal_states=terminal_states)
26
+ return render_template(
27
+ "system.yaml.j2",
28
+ profile=profile,
29
+ terminal_states=terminal_states,
30
+ output_module=output_module,
31
+ )
@@ -0,0 +1,209 @@
1
+ """Jinja2 template rendering engine for code generation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path # noqa: TC003
6
+ from typing import TYPE_CHECKING
7
+
8
+ from jinja2 import Environment, FileSystemLoader
9
+
10
+ if TYPE_CHECKING:
11
+ from hexdag_forge.domain.business_profile import BusinessProfile, EntitySpec
12
+
13
+ _TEMPLATE_DIR = Path(__file__).parent.parent / "templates"
14
+
15
+
16
+ # ── Jinja2 filters ──────────────────────────────────────────
17
+
18
+
19
+ def _to_pascal(value: str) -> str:
20
+ """Convert snake_case to PascalCase: ``freight_brokerage`` → ``FreightBrokerage``."""
21
+ return "".join(word.capitalize() for word in value.split("_"))
22
+
23
+
24
+ def _humanize(value: str) -> str:
25
+ """Convert snake_case to human-readable: ``company_name`` → ``Company Name``."""
26
+ return value.replace("_", " ").title()
27
+
28
+
29
+ def _status_color(state: str) -> str:
30
+ """Map a state name to a CSS badge color class using naming heuristics."""
31
+ s = state.upper()
32
+ # Exact matches
33
+ _GREEN = {
34
+ "COMPLETED",
35
+ "DELIVERED",
36
+ "DONE",
37
+ "APPROVED",
38
+ "ACTIVE",
39
+ "PAID",
40
+ "RESOLVED",
41
+ "AVAILABLE",
42
+ "PICKED_UP",
43
+ }
44
+ _RED = {
45
+ "CANCELLED",
46
+ "REJECTED",
47
+ "FAILED",
48
+ "SUSPENDED",
49
+ "BLOCKED",
50
+ "EXPIRED",
51
+ "DELETED",
52
+ "TERMINATED",
53
+ "VOID",
54
+ "RETIRED",
55
+ "CLOSED",
56
+ }
57
+ _BLUE = {
58
+ "DISPATCHED",
59
+ "SENT",
60
+ "ONBOARDING",
61
+ "CONFIRMED",
62
+ "ASSIGNED",
63
+ }
64
+ _PURPLE = {
65
+ "IN_TRANSIT",
66
+ "IN_PROGRESS",
67
+ "PROCESSING",
68
+ "ON_TRIP",
69
+ "IN_USE",
70
+ "COVERED",
71
+ }
72
+ _YELLOW = {
73
+ "SUSPENDED",
74
+ "MAINTENANCE",
75
+ "INVOICED",
76
+ "OVERDUE",
77
+ "OFF_DUTY",
78
+ }
79
+ _GRAY = {
80
+ "DRAFT",
81
+ "PENDING",
82
+ "NEW",
83
+ "POSTED",
84
+ "PROSPECT",
85
+ "CREATED",
86
+ "QUEUED",
87
+ }
88
+
89
+ if s in _GREEN:
90
+ return "badge-green"
91
+ if s in _RED:
92
+ return "badge-red"
93
+ if s in _PURPLE:
94
+ return "badge-purple"
95
+ if s in _YELLOW:
96
+ return "badge-yellow"
97
+ if s in _BLUE:
98
+ return "badge-blue"
99
+ if s in _GRAY:
100
+ return "badge-gray"
101
+
102
+ # Keyword heuristic fallback
103
+ if any(kw in s for kw in ("COMPLET", "DELIVER", "DONE", "APPROV", "ACTIV", "AVAIL", "PAID")):
104
+ return "badge-green"
105
+ if any(kw in s for kw in ("CANCEL", "REJECT", "FAIL", "TERMINAT", "VOID", "RETIR", "CLOSE")):
106
+ return "badge-red"
107
+ if any(kw in s for kw in ("TRANSIT", "TRIP", "PROGRESS", "PROCESS", "USE")):
108
+ return "badge-purple"
109
+ if any(kw in s for kw in ("SUSPEND", "MAINT", "OVERDUE")):
110
+ return "badge-yellow"
111
+ if any(kw in s for kw in ("DISPATCH", "SENT", "ONBOARD", "REVIEW")):
112
+ return "badge-blue"
113
+ return "badge-gray"
114
+
115
+
116
+ def _transition_color(state: str) -> str:
117
+ """Map a target state to a button CSS class for transition buttons."""
118
+ color = _status_color(state)
119
+ return {
120
+ "badge-green": "btn-success",
121
+ "badge-red": "btn-danger",
122
+ "badge-purple": "btn-primary",
123
+ "badge-blue": "btn-primary",
124
+ "badge-yellow": "btn-warning",
125
+ "badge-gray": "btn-outline",
126
+ }.get(color, "btn-outline")
127
+
128
+
129
+ # ── Context builders ────────────────────────────────────────
130
+
131
+
132
+ def build_badge_map(profile: BusinessProfile) -> dict[str, str]:
133
+ """Build a {state_name: badge_class} map for all states across all entities."""
134
+ result: dict[str, str] = {}
135
+ for entity in profile.entities:
136
+ for state in entity.states:
137
+ result[state] = _status_color(state)
138
+ return result
139
+
140
+
141
+ def build_list_columns(entity: EntitySpec) -> list[object]:
142
+ """Select fields suitable for list table display (skip text/json, cap at 5)."""
143
+ return [f for f in entity.fields if f.type not in ("text", "json")][:5]
144
+
145
+
146
+ def build_fk_fields(entity: EntitySpec) -> list[object]:
147
+ """Return belongs_to relationship specs for select_related."""
148
+ return [r for r in entity.relationships if r.kind == "belongs_to"]
149
+
150
+
151
+ # ── Environments ────────────────────────────────────────────
152
+
153
+
154
+ def _register_filters(env: Environment) -> None:
155
+ """Register all custom filters on a Jinja2 environment."""
156
+ env.filters["to_pascal"] = _to_pascal
157
+ env.filters["humanize"] = _humanize
158
+ env.filters["status_color"] = _status_color
159
+ env.filters["transition_color"] = _transition_color
160
+
161
+
162
+ def create_env() -> Environment:
163
+ """Create a Jinja2 environment configured for Python code generation."""
164
+ env = Environment( # nosec B701 — code generation templates, not HTML
165
+ loader=FileSystemLoader(str(_TEMPLATE_DIR)),
166
+ keep_trailing_newline=True,
167
+ trim_blocks=True,
168
+ lstrip_blocks=True,
169
+ autoescape=False,
170
+ )
171
+ _register_filters(env)
172
+ return env
173
+
174
+
175
+ def create_html_env() -> Environment:
176
+ """Create a Jinja2 environment for templates that OUTPUT Django template syntax.
177
+
178
+ Uses alternate delimiters to avoid collision with Django's ``{% %}`` and ``{{ }}``.
179
+ Jinja2 generation uses ``[% %]`` and ``[[ ]]``, Django tags pass through untouched.
180
+ """
181
+ env = Environment( # nosec B701 — code generation templates, not HTML
182
+ loader=FileSystemLoader(str(_TEMPLATE_DIR)),
183
+ keep_trailing_newline=True,
184
+ trim_blocks=True,
185
+ lstrip_blocks=True,
186
+ autoescape=False,
187
+ block_start_string="[%",
188
+ block_end_string="%]",
189
+ variable_start_string="[[",
190
+ variable_end_string="]]",
191
+ comment_start_string="[#",
192
+ comment_end_string="#]",
193
+ )
194
+ _register_filters(env)
195
+ return env
196
+
197
+
198
+ def render_template(template_name: str, **context: object) -> str:
199
+ """Render a Jinja2 template with the given context (standard delimiters)."""
200
+ env = create_env()
201
+ template = env.get_template(template_name)
202
+ return template.render(**context)
203
+
204
+
205
+ def render_html_template(template_name: str, **context: object) -> str:
206
+ """Render a Jinja2 template that outputs Django template syntax (alt delimiters)."""
207
+ env = create_html_env()
208
+ template = env.get_template(template_name)
209
+ return template.render(**context)
@@ -117,7 +117,7 @@ async def forge_migrate(new_profile_json: str, output_dir: str = "./generated")
117
117
  migration_info = {"migration": filename, "operations": content.count("migrations.")}
118
118
 
119
119
  # Regenerate all files with new profile
120
- gen_result = assemble(new_profile, output)
120
+ gen_result = assemble(new_profile, output, skip_migration=True)
121
121
 
122
122
  return json.dumps(
123
123
  {
@@ -0,0 +1 @@
1
+ """Runtime adapters for forge-generated systems."""
@@ -0,0 +1,124 @@
1
+ """Django ORM implementation of SupportsEntityStore.
2
+
3
+ Uses ``django.apps.registry.apps.get_model()`` for dynamic model lookup,
4
+ matching forge's pattern where entity names map directly to Django model names.
5
+
6
+ Requires Django to be installed and configured (``DJANGO_SETTINGS_MODULE``).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any
12
+
13
+
14
+ class DjangoEntityStore:
15
+ """Django ORM adapter for forge-generated entity models.
16
+
17
+ Parameters
18
+ ----------
19
+ app_label:
20
+ The Django app label (e.g. ``"freight_brokerage"``).
21
+ Must match the app_label used by forge during generation.
22
+ """
23
+
24
+ def __init__(self, app_label: str) -> None:
25
+ self._app_label = app_label
26
+
27
+ def _get_model(self, entity_type: str) -> Any:
28
+ """Resolve a Django model class from entity_type name."""
29
+ from django.apps import apps # type: ignore[import-not-found] # noqa: PLC0415
30
+
31
+ return apps.get_model(self._app_label, entity_type)
32
+
33
+ @staticmethod
34
+ def _to_dict(obj: Any) -> dict[str, Any]:
35
+ """Convert a Django model instance to a dict."""
36
+ from django.forms.models import (
37
+ model_to_dict, # type: ignore[import-not-found] # noqa: PLC0415
38
+ )
39
+
40
+ d: dict[str, Any] = model_to_dict(obj)
41
+ d["id"] = str(obj.pk)
42
+ return d
43
+
44
+ async def create(
45
+ self, entity_type: str, data: dict[str, Any], initial_state: str
46
+ ) -> dict[str, Any]:
47
+ """Create a new entity record via Django ORM."""
48
+ from asgiref.sync import sync_to_async # type: ignore[import-not-found] # noqa: PLC0415
49
+
50
+ model = self._get_model(entity_type)
51
+ obj = await sync_to_async(model.objects.create)(**data, status=initial_state)
52
+ return self._to_dict(obj)
53
+
54
+ async def get(self, entity_type: str, entity_id: str) -> dict[str, Any]:
55
+ """Retrieve a single entity by primary key."""
56
+ from asgiref.sync import sync_to_async # type: ignore[import-not-found] # noqa: PLC0415
57
+
58
+ model = self._get_model(entity_type)
59
+ obj = await sync_to_async(model.objects.get)(pk=entity_id)
60
+ return self._to_dict(obj)
61
+
62
+ async def list_all(
63
+ self,
64
+ entity_type: str,
65
+ status: str | None = None,
66
+ limit: int = 50,
67
+ ) -> list[dict[str, Any]]:
68
+ """List entities, optionally filtered by status field."""
69
+ from asgiref.sync import sync_to_async # type: ignore[import-not-found] # noqa: PLC0415
70
+
71
+ model = self._get_model(entity_type)
72
+ qs = model.objects.all()
73
+ if status is not None:
74
+ qs = qs.filter(status=status)
75
+ qs = qs[:limit]
76
+ objects = await sync_to_async(list)(qs)
77
+ return [self._to_dict(obj) for obj in objects]
78
+
79
+ async def update(
80
+ self, entity_type: str, entity_id: str, data: dict[str, Any]
81
+ ) -> dict[str, Any]:
82
+ """Update fields on an existing entity."""
83
+ from asgiref.sync import sync_to_async # type: ignore[import-not-found] # noqa: PLC0415
84
+
85
+ model = self._get_model(entity_type)
86
+ obj = await sync_to_async(model.objects.get)(pk=entity_id)
87
+ for key, value in data.items():
88
+ setattr(obj, key, value)
89
+ await sync_to_async(obj.save)()
90
+ return self._to_dict(obj)
91
+
92
+ async def delete(self, entity_type: str, entity_id: str) -> dict[str, Any]:
93
+ """Delete an entity by primary key."""
94
+ from asgiref.sync import sync_to_async # type: ignore[import-not-found] # noqa: PLC0415
95
+
96
+ model = self._get_model(entity_type)
97
+ obj = await sync_to_async(model.objects.get)(pk=entity_id)
98
+ result = self._to_dict(obj)
99
+ await sync_to_async(obj.delete)()
100
+ return {"deleted": True, **result}
101
+
102
+ async def transition(
103
+ self,
104
+ entity_type: str,
105
+ entity_id: str,
106
+ to_state: str,
107
+ reason: str = "",
108
+ ) -> dict[str, Any]:
109
+ """Transition an entity to a new state by updating the status field."""
110
+ from asgiref.sync import sync_to_async # type: ignore[import-not-found] # noqa: PLC0415
111
+
112
+ model = self._get_model(entity_type)
113
+ obj = await sync_to_async(model.objects.get)(pk=entity_id)
114
+ from_state = obj.status
115
+ obj.status = to_state
116
+ await sync_to_async(obj.save)(update_fields=["status"])
117
+ return {
118
+ "id": str(obj.pk),
119
+ "entity_type": entity_type,
120
+ "from_state": from_state,
121
+ "to_state": to_state,
122
+ "reason": reason,
123
+ "status": to_state,
124
+ }