hexdag-forge 0.2.0.dev4__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 (76) hide show
  1. {hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/PKG-INFO +1 -1
  2. {hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/hexdag_forge/cli.py +32 -0
  3. {hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/hexdag_forge/domain/business_profile.py +1 -0
  4. {hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/hexdag_forge/generator/assembler.py +18 -2
  5. {hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/hexdag_forge/generator/migration_generator.py +45 -0
  6. {hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/hexdag_forge/generator/system_generator.py +12 -2
  7. hexdag_forge-0.2.0.dev5/hexdag_forge/runtime/__init__.py +1 -0
  8. hexdag_forge-0.2.0.dev5/hexdag_forge/runtime/django_store.py +124 -0
  9. hexdag_forge-0.2.0.dev5/hexdag_forge/runtime/entity_store.py +59 -0
  10. hexdag_forge-0.2.0.dev5/hexdag_forge/runtime/memory_store.py +95 -0
  11. {hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/hexdag_forge/templates/chat_pipeline.yaml.j2 +7 -1
  12. {hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/hexdag_forge/templates/django_base.html.j2 +6 -2
  13. hexdag_forge-0.2.0.dev5/hexdag_forge/templates/django_chat.html.j2 +119 -0
  14. hexdag_forge-0.2.0.dev5/hexdag_forge/templates/django_chat_conversations.html.j2 +27 -0
  15. hexdag_forge-0.2.0.dev5/hexdag_forge/templates/django_chat_messages_partial.html.j2 +10 -0
  16. {hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/hexdag_forge/templates/pipeline.yaml.j2 +6 -10
  17. {hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/hexdag_forge/templates/system.yaml.j2 +9 -1
  18. {hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/pyproject.toml +1 -1
  19. {hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/tests/test_generator/test_assembler.py +48 -3
  20. hexdag_forge-0.2.0.dev5/tests/test_runtime/__init__.py +0 -0
  21. hexdag_forge-0.2.0.dev5/tests/test_runtime/test_memory_store.py +129 -0
  22. hexdag_forge-0.2.0.dev4/hexdag_forge/templates/django_chat.html.j2 +0 -43
  23. hexdag_forge-0.2.0.dev4/hexdag_forge/templates/django_chat_conversations.html.j2 +0 -29
  24. hexdag_forge-0.2.0.dev4/hexdag_forge/templates/django_chat_messages_partial.html.j2 +0 -8
  25. {hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/.gitignore +0 -0
  26. {hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/LICENSE +0 -0
  27. {hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/README.md +0 -0
  28. {hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/hexdag_forge/__init__.py +0 -0
  29. {hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/hexdag_forge/domain/__init__.py +0 -0
  30. {hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/hexdag_forge/domain/enums.py +0 -0
  31. {hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/hexdag_forge/domain/generated_system.py +0 -0
  32. {hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/hexdag_forge/domain/profile_diff.py +0 -0
  33. {hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/hexdag_forge/generator/__init__.py +0 -0
  34. {hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/hexdag_forge/generator/diff.py +0 -0
  35. {hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/hexdag_forge/generator/django_generator.py +0 -0
  36. {hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/hexdag_forge/generator/entity_generator.py +0 -0
  37. {hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/hexdag_forge/generator/field_mapping.py +0 -0
  38. {hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/hexdag_forge/generator/pipeline_generator.py +0 -0
  39. {hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/hexdag_forge/generator/service_generator.py +0 -0
  40. {hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/hexdag_forge/generator/template_engine.py +0 -0
  41. {hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/hexdag_forge/mcp/__init__.py +0 -0
  42. {hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/hexdag_forge/mcp/__main__.py +0 -0
  43. {hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/hexdag_forge/mcp/server.py +0 -0
  44. {hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/hexdag_forge/skills/forge.md +0 -0
  45. {hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/hexdag_forge/skills/forge_entity.md +0 -0
  46. {hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/hexdag_forge/skills/forge_implement.md +0 -0
  47. {hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/hexdag_forge/skills/forge_test.md +0 -0
  48. {hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/hexdag_forge/templates/django_admin.py.j2 +0 -0
  49. {hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/hexdag_forge/templates/django_apps.py.j2 +0 -0
  50. {hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/hexdag_forge/templates/django_chat_agent.py.j2 +0 -0
  51. {hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/hexdag_forge/templates/django_dashboard.html.j2 +0 -0
  52. {hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/hexdag_forge/templates/django_entity_confirm_delete.html.j2 +0 -0
  53. {hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/hexdag_forge/templates/django_entity_detail.html.j2 +0 -0
  54. {hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/hexdag_forge/templates/django_entity_detail_partial.html.j2 +0 -0
  55. {hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/hexdag_forge/templates/django_entity_form.html.j2 +0 -0
  56. {hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/hexdag_forge/templates/django_entity_form_modal.html.j2 +0 -0
  57. {hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/hexdag_forge/templates/django_entity_list.html.j2 +0 -0
  58. {hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/hexdag_forge/templates/django_entity_list_partial.html.j2 +0 -0
  59. {hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/hexdag_forge/templates/django_forms.py.j2 +0 -0
  60. {hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/hexdag_forge/templates/django_logged_out.html.j2 +0 -0
  61. {hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/hexdag_forge/templates/django_login.html.j2 +0 -0
  62. {hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/hexdag_forge/templates/django_manage.py.j2 +0 -0
  63. {hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/hexdag_forge/templates/django_migration.py.j2 +0 -0
  64. {hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/hexdag_forge/templates/django_model.py.j2 +0 -0
  65. {hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/hexdag_forge/templates/django_register.html.j2 +0 -0
  66. {hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/hexdag_forge/templates/django_settings.py.j2 +0 -0
  67. {hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/hexdag_forge/templates/django_templatetags.py.j2 +0 -0
  68. {hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/hexdag_forge/templates/django_urls.py.j2 +0 -0
  69. {hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/hexdag_forge/templates/django_views.py.j2 +0 -0
  70. {hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/hexdag_forge/templates/service.py.j2 +0 -0
  71. {hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/hexdag_forge/templates/state_machines.py.j2 +0 -0
  72. {hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/tests/__init__.py +0 -0
  73. {hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/tests/test_domain.py +0 -0
  74. {hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/tests/test_generator/__init__.py +0 -0
  75. {hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/tests/test_generator/test_diff.py +0 -0
  76. {hexdag_forge-0.2.0.dev4 → 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.dev4
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,6 +18,20 @@ 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 _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
+
21
35
  def assemble(
22
36
  profile: BusinessProfile,
23
37
  output_dir: Path,
@@ -36,11 +50,12 @@ def assemble(
36
50
  GeneratedSystem with metadata about what was generated.
37
51
  """
38
52
  output_dir.mkdir(parents=True, exist_ok=True)
53
+ output_module = profile.output_module or _derive_output_module(output_dir)
39
54
 
40
55
  # Generate all artifacts
41
56
  pipelines = generate_pipelines(profile)
42
57
  services = generate_services(profile)
43
- system_yaml = generate_system(profile)
58
+ system_yaml = generate_system(profile, output_module=output_module)
44
59
  django_files = generate_django(profile)
45
60
 
46
61
  # Write pipelines
@@ -49,9 +64,10 @@ def assemble(
49
64
  for filename, content in pipelines.items():
50
65
  (pipelines_dir / filename).write_text(content)
51
66
 
52
- # Write services
67
+ # Write services as a proper Python package
53
68
  services_dir = output_dir / "services"
54
69
  services_dir.mkdir(exist_ok=True)
70
+ (services_dir / "__init__.py").write_text("")
55
71
  for filename, content in services.items():
56
72
  (services_dir / filename).write_text(content)
57
73
 
@@ -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
+ ]
@@ -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 @@
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
+ }
@@ -0,0 +1,59 @@
1
+ """SupportsEntityStore — port protocol for forge-generated services.
2
+
3
+ Forge-generated services call ``self._db.create()``, ``.get()``,
4
+ ``.list_all()``, ``.update()``, ``.delete()``, and ``.transition()``.
5
+ This protocol formalises that interface so adapters can be swapped
6
+ (Django ORM, in-memory, SQLAlchemy, etc.).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any, Protocol, runtime_checkable
12
+
13
+
14
+ @runtime_checkable
15
+ class SupportsEntityStore(Protocol):
16
+ """CRUD + state-transition storage for forge-generated entities."""
17
+
18
+ async def create(
19
+ self, entity_type: str, data: dict[str, Any], initial_state: str
20
+ ) -> dict[str, Any]:
21
+ """Create a new entity record.
22
+
23
+ Returns:
24
+ Dict with at least ``id`` and ``status`` keys.
25
+ """
26
+ ...
27
+
28
+ async def get(self, entity_type: str, entity_id: str) -> dict[str, Any]:
29
+ """Retrieve a single entity by ID."""
30
+ ...
31
+
32
+ async def list_all(
33
+ self,
34
+ entity_type: str,
35
+ status: str | None = None,
36
+ limit: int = 50,
37
+ ) -> list[dict[str, Any]]:
38
+ """List entities, optionally filtered by status."""
39
+ ...
40
+
41
+ async def update(
42
+ self, entity_type: str, entity_id: str, data: dict[str, Any]
43
+ ) -> dict[str, Any]:
44
+ """Update fields on an existing entity."""
45
+ ...
46
+
47
+ async def delete(self, entity_type: str, entity_id: str) -> dict[str, Any]:
48
+ """Delete an entity. Returns metadata about the deletion."""
49
+ ...
50
+
51
+ async def transition(
52
+ self,
53
+ entity_type: str,
54
+ entity_id: str,
55
+ to_state: str,
56
+ reason: str = "",
57
+ ) -> dict[str, Any]:
58
+ """Transition an entity to a new state."""
59
+ ...
@@ -0,0 +1,95 @@
1
+ """In-memory implementation of SupportsEntityStore.
2
+
3
+ No external dependencies — useful for testing, demos, and quick iteration.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import uuid
9
+ from typing import Any
10
+
11
+
12
+ class InMemoryEntityStore:
13
+ """Dict-backed entity store for testing and demos.
14
+
15
+ Data is lost when the process exits. Each entity type is stored in
16
+ a separate dict keyed by entity ID.
17
+ """
18
+
19
+ def __init__(self) -> None:
20
+ self._data: dict[str, dict[str, dict[str, Any]]] = {}
21
+
22
+ async def create(
23
+ self, entity_type: str, data: dict[str, Any], initial_state: str
24
+ ) -> dict[str, Any]:
25
+ """Create a new entity with a generated UUID."""
26
+ store = self._data.setdefault(entity_type, {})
27
+ entity_id = str(uuid.uuid4())
28
+ record = {"id": entity_id, "status": initial_state, **data}
29
+ store[entity_id] = record
30
+ return dict(record)
31
+
32
+ async def get(self, entity_type: str, entity_id: str) -> dict[str, Any]:
33
+ """Retrieve an entity by ID. Raises KeyError if not found."""
34
+ store = self._data.get(entity_type, {})
35
+ if entity_id not in store:
36
+ msg = f"{entity_type} with id {entity_id!r} not found"
37
+ raise KeyError(msg)
38
+ return dict(store[entity_id])
39
+
40
+ async def list_all(
41
+ self,
42
+ entity_type: str,
43
+ status: str | None = None,
44
+ limit: int = 50,
45
+ ) -> list[dict[str, Any]]:
46
+ """List entities, optionally filtered by status."""
47
+ store = self._data.get(entity_type, {})
48
+ records = list(store.values())
49
+ if status is not None:
50
+ records = [r for r in records if r.get("status") == status]
51
+ return [dict(r) for r in records[:limit]]
52
+
53
+ async def update(
54
+ self, entity_type: str, entity_id: str, data: dict[str, Any]
55
+ ) -> dict[str, Any]:
56
+ """Update fields on an existing entity."""
57
+ store = self._data.get(entity_type, {})
58
+ if entity_id not in store:
59
+ msg = f"{entity_type} with id {entity_id!r} not found"
60
+ raise KeyError(msg)
61
+ store[entity_id].update(data)
62
+ return dict(store[entity_id])
63
+
64
+ async def delete(self, entity_type: str, entity_id: str) -> dict[str, Any]:
65
+ """Delete an entity. Raises KeyError if not found."""
66
+ store = self._data.get(entity_type, {})
67
+ if entity_id not in store:
68
+ msg = f"{entity_type} with id {entity_id!r} not found"
69
+ raise KeyError(msg)
70
+ record = store.pop(entity_id)
71
+ return {"deleted": True, "id": entity_id, "entity_type": entity_type, **record}
72
+
73
+ async def transition(
74
+ self,
75
+ entity_type: str,
76
+ entity_id: str,
77
+ to_state: str,
78
+ reason: str = "",
79
+ ) -> dict[str, Any]:
80
+ """Transition an entity to a new state."""
81
+ store = self._data.get(entity_type, {})
82
+ if entity_id not in store:
83
+ msg = f"{entity_type} with id {entity_id!r} not found"
84
+ raise KeyError(msg)
85
+ record = store[entity_id]
86
+ from_state = record["status"]
87
+ record["status"] = to_state
88
+ return {
89
+ "id": entity_id,
90
+ "entity_type": entity_type,
91
+ "from_state": from_state,
92
+ "to_state": to_state,
93
+ "reason": reason,
94
+ **record,
95
+ }
@@ -4,6 +4,12 @@ metadata:
4
4
  name: chat_agent
5
5
  description: "Chat agent for {{ profile.business_name }} — routes user messages to entity services"
6
6
  spec:
7
+ services:
8
+ {% for entity in profile.entities %}
9
+ {{ entity.name }}_svc:
10
+ class: "services.{{ entity.name | capitalize }}Service"
11
+ {% endfor %}
12
+
7
13
  nodes:
8
14
  - kind: agent_node
9
15
  metadata:
@@ -27,7 +33,7 @@ spec:
27
33
  Always confirm destructive actions before executing them.
28
34
  When listing results, format them clearly.
29
35
 
30
- User message: {% raw %}{{message}}{% endraw %}
36
+ User message: {{ '{{message}}' }}
31
37
  config:
32
38
  max_steps: 10
33
39
  dependencies: []
@@ -107,14 +107,18 @@
107
107
  <li><a href="/">&#9632; Dashboard</a></li>
108
108
  </ul>
109
109
  [% if profile.modules %]
110
+ <div class="sidebar-section">Modules</div>
111
+ <ul>
110
112
  [% for module in profile.modules %]
111
- <div class="sidebar-section">[[ module | humanize ]]</div>
113
+ <li><small>[[ module | humanize ]]</small></li>
114
+ [% endfor %]
115
+ </ul>
116
+ <div class="sidebar-section">Entities</div>
112
117
  <ul>
113
118
  [% for entity in profile.entities %]
114
119
  <li><a href="/[[ entity.name ]]/">[[ entity.display_name ]]</a></li>
115
120
  [% endfor %]
116
121
  </ul>
117
- [% endfor %]
118
122
  [% else %]
119
123
  <div class="sidebar-section">Entities</div>
120
124
  <ul>
@@ -0,0 +1,119 @@
1
+ {% extends "base.html" %}
2
+ {% block title %}Chat — [[ profile.business_name ]]{% endblock %}
3
+ {% block heading %}<h2>Chat</h2>{% endblock %}
4
+ {% block content %}
5
+ <div class="chat-layout">
6
+ <aside class="chat-sidebar">
7
+ <form method="post" action="/chat/new/">
8
+ {% csrf_token %}
9
+ <button type="submit" class="btn btn-primary" style="width: 100%; margin-bottom: 0.75rem;">+ New Chat</button>
10
+ </form>
11
+ <ul class="chat-conv-list">
12
+ {% for conv in conversations %}
13
+ <li>
14
+ <a href="/chat/{{ conv.pk }}/"
15
+ class="chat-conv-item{% if conversation and conversation.pk == conv.pk %} active{% endif %}">
16
+ <span class="chat-conv-title">{{ conv.title|truncatechars:30 }}</span>
17
+ <span class="chat-conv-time">{{ conv.updated_at|date:"M d" }}</span>
18
+ </a>
19
+ </li>
20
+ {% empty %}
21
+ <li class="empty-state" style="padding: 1rem;"><small>No conversations yet</small></li>
22
+ {% endfor %}
23
+ </ul>
24
+ </aside>
25
+ <div class="chat-main">
26
+ {% if conversation %}
27
+ <div class="chat-header">
28
+ <strong>{{ conversation.title|truncatechars:50 }}</strong>
29
+ <small style="color: #6b7280;">{{ conversation.messages.count }} messages</small>
30
+ </div>
31
+ <div id="messages" class="chat-messages">
32
+ {% include "chat/_messages.html" %}
33
+ </div>
34
+ <form class="chat-input"
35
+ hx-post="/chat/send/"
36
+ hx-target="#messages"
37
+ hx-swap="innerHTML"
38
+ hx-on::after-request="this.querySelector('textarea').value = ''; document.getElementById('messages').scrollTop = document.getElementById('messages').scrollHeight;"
39
+ hx-indicator="#send-indicator">
40
+ {% csrf_token %}
41
+ <input type="hidden" name="conversation_id" value="{{ conversation.pk }}">
42
+ <textarea name="content"
43
+ rows="1"
44
+ placeholder="Type a message..."
45
+ onkeydown="if(event.key==='Enter'&&!event.shiftKey){event.preventDefault();this.closest('form').requestSubmit();}"></textarea>
46
+ <button type="submit" class="btn btn-primary">
47
+ <span id="send-indicator" class="htmx-indicator" style="display:none;">
48
+ <span class="spinner"></span>
49
+ </span>
50
+ <span class="send-label">Send</span>
51
+ </button>
52
+ </form>
53
+ {% else %}
54
+ <div class="chat-empty">
55
+ <div class="chat-empty-icon">&#9993;</div>
56
+ <h3>Start a conversation</h3>
57
+ <p>Create a new chat or select one from the sidebar.</p>
58
+ <form method="post" action="/chat/new/" style="margin-top: 1rem;">
59
+ {% csrf_token %}
60
+ <button type="submit" class="btn btn-primary">+ New Chat</button>
61
+ </form>
62
+ </div>
63
+ {% endif %}
64
+ </div>
65
+ </div>
66
+
67
+ <style>
68
+ /* ── Chat Layout ───────────────────────── */
69
+ .chat-layout { display: flex; height: calc(100vh - 120px); background: #fff; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,0.05); overflow: hidden; }
70
+
71
+ /* ── Sidebar ────────────────────────────── */
72
+ .chat-sidebar { width: 240px; border-right: 1px solid #e5e7eb; padding: 1rem; overflow-y: auto; background: #f9fafb; flex-shrink: 0; }
73
+ .chat-conv-list { list-style: none; }
74
+ .chat-conv-item { display: flex; flex-direction: column; padding: 0.5rem 0.75rem; border-radius: 6px; color: #374151; font-size: 0.85rem; transition: background 0.15s; }
75
+ .chat-conv-item:hover { background: #e5e7eb; text-decoration: none; }
76
+ .chat-conv-item.active { background: #dbeafe; color: #1e40af; }
77
+ .chat-conv-title { font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
78
+ .chat-conv-time { font-size: 0.7rem; color: #9ca3af; margin-top: 0.15rem; }
79
+
80
+ /* ── Main Area ──────────────────────────── */
81
+ .chat-main { flex: 1; display: flex; flex-direction: column; min-width: 0; }
82
+ .chat-header { padding: 0.75rem 1.25rem; border-bottom: 1px solid #e5e7eb; display: flex; justify-content: space-between; align-items: center; background: #fff; }
83
+ .chat-messages { flex: 1; overflow-y: auto; padding: 1.25rem; display: flex; flex-direction: column; gap: 0.75rem; }
84
+
85
+ /* ── Message Bubbles ────────────────────── */
86
+ .msg { max-width: 75%; padding: 0.6rem 0.9rem; border-radius: 12px; font-size: 0.85rem; line-height: 1.5; word-wrap: break-word; }
87
+ .msg-user { align-self: flex-end; background: #2563eb; color: #fff; border-bottom-right-radius: 4px; }
88
+ .msg-system { align-self: flex-start; background: #f3f4f6; color: #1f2937; border-bottom-left-radius: 4px; }
89
+ .msg-meta { font-size: 0.65rem; margin-top: 0.25rem; opacity: 0.7; }
90
+ .msg-user .msg-meta { text-align: right; }
91
+ .msg-content { white-space: pre-wrap; }
92
+
93
+ /* ── Input Bar ──────────────────────────── */
94
+ .chat-input { display: flex; gap: 0.5rem; padding: 0.75rem 1.25rem; border-top: 1px solid #e5e7eb; background: #fff; align-items: flex-end; }
95
+ .chat-input textarea { flex: 1; padding: 0.5rem 0.75rem; border: 1px solid #d1d5db; border-radius: 8px; font-size: 0.85rem; font-family: inherit; resize: none; max-height: 120px; line-height: 1.4; }
96
+ .chat-input textarea:focus { outline: none; border-color: #2563eb; box-shadow: 0 0 0 2px rgba(37,99,235,0.15); }
97
+ .chat-input button { flex-shrink: 0; height: 36px; min-width: 64px; }
98
+
99
+ /* ── Empty State ────────────────────────── */
100
+ .chat-empty { display: flex; flex-direction: column; align-items: center; justify-content: center; flex: 1; color: #6b7280; }
101
+ .chat-empty-icon { font-size: 2.5rem; margin-bottom: 0.75rem; }
102
+ .chat-empty h3 { color: #374151; margin-bottom: 0.35rem; }
103
+ .chat-empty p { font-size: 0.85rem; }
104
+
105
+ /* ── Spinner ─────────────────────────────── */
106
+ .spinner { display: inline-block; width: 14px; height: 14px; border: 2px solid #fff; border-top-color: transparent; border-radius: 50%; animation: spin 0.6s linear infinite; }
107
+ @keyframes spin { to { transform: rotate(360deg); } }
108
+ .htmx-request .send-label { display: none; }
109
+ .htmx-request .htmx-indicator { display: inline-block !important; }
110
+ </style>
111
+
112
+ <script>
113
+ // Auto-scroll to bottom on page load
114
+ document.addEventListener("DOMContentLoaded", function() {
115
+ var el = document.getElementById("messages");
116
+ if (el) el.scrollTop = el.scrollHeight;
117
+ });
118
+ </script>
119
+ {% endblock %}
@@ -0,0 +1,27 @@
1
+ {% extends "base.html" %}
2
+ {% block title %}Chat — [[ profile.business_name ]]{% endblock %}
3
+ {% block heading %}<h2>Chat</h2>{% endblock %}
4
+ {% block content %}
5
+ <div class="toolbar">
6
+ <div></div>
7
+ <form method="post" action="/chat/new/">
8
+ {% csrf_token %}
9
+ <button type="submit" class="btn btn-primary">+ New Conversation</button>
10
+ </form>
11
+ </div>
12
+ {% if conversations %}
13
+ <div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 1rem;">
14
+ {% for conv in conversations %}
15
+ <a href="/chat/{{ conv.pk }}/" class="card" style="text-decoration: none; color: inherit; transition: box-shadow 0.15s;">
16
+ <div style="font-weight: 600; color: #111827; margin-bottom: 0.35rem;">{{ conv.title|truncatechars:40 }}</div>
17
+ <div style="font-size: 0.78rem; color: #6b7280;">{{ conv.updated_at|date:"M d, H:i" }}</div>
18
+ </a>
19
+ {% endfor %}
20
+ </div>
21
+ {% else %}
22
+ <div class="empty-state" style="padding: 3rem;">
23
+ <p style="font-size: 1rem; margin-bottom: 0.5rem;">No conversations yet</p>
24
+ <p style="font-size: 0.85rem;">Start a new conversation to chat with the AI assistant.</p>
25
+ </div>
26
+ {% endif %}
27
+ {% endblock %}
@@ -0,0 +1,10 @@
1
+ {% for msg in messages %}
2
+ <div class="msg msg-{{ msg.sender }}">
3
+ <div class="msg-content">{{ msg.content }}</div>
4
+ <div class="msg-meta">{{ msg.created_at|date:"H:i" }}</div>
5
+ </div>
6
+ {% empty %}
7
+ <div class="chat-empty" style="padding: 2rem;">
8
+ <p><small>No messages yet. Send one to get started.</small></p>
9
+ </div>
10
+ {% endfor %}
@@ -5,19 +5,15 @@ metadata:
5
5
  description: "Lifecycle pipeline for {{ entity.display_name }}"
6
6
 
7
7
  spec:
8
- services:
9
- db:
10
- class: "services.{{ entity.name | capitalize }}Service"
11
-
12
8
  nodes:
13
9
  - kind: service_call_node
14
10
  metadata:
15
11
  name: get_{{ entity.name }}
16
12
  spec:
17
- service: db
13
+ service: {{ entity.name }}_svc
18
14
  method: get_{{ entity.name }}
19
15
  input_mapping:
20
- {{ entity.name }}_id: "$input.{{ entity.name }}_id"
16
+ {{ entity.name }}_id: "$input.entity_id"
21
17
 
22
18
  - kind: expression_node
23
19
  metadata:
@@ -25,16 +21,16 @@ spec:
25
21
  spec:
26
22
  expressions:
27
23
  current_state: "get_{{ entity.name }}.status"
28
- target_state: "$input.target_state"
24
+ target_state: "$input.to_state"
29
25
  output_fields: [current_state, target_state]
30
26
 
31
27
  - kind: service_call_node
32
28
  metadata:
33
29
  name: do_transition
34
30
  spec:
35
- service: db
31
+ service: {{ entity.name }}_svc
36
32
  method: transition_{{ entity.name }}
37
33
  input_mapping:
38
- {{ entity.name }}_id: "$input.{{ entity.name }}_id"
39
- to_state: "validate_transition.target_state"
34
+ {{ entity.name }}_id: "$input.entity_id"
35
+ to_state: "$input.to_state"
40
36
  reason: "$input.reason"
@@ -5,6 +5,12 @@ metadata:
5
5
  description: "{{ profile.description | truncate(200) }}"
6
6
 
7
7
  spec:
8
+ ports:
9
+ entity_store:
10
+ adapter: "hexdag_forge.runtime.django_store.DjangoEntityStore"
11
+ config:
12
+ app_label: "{{ profile.app_label }}"
13
+
8
14
  state_machines:
9
15
  {% for entity in profile.entities %}
10
16
  {{ entity.name }}:
@@ -41,5 +47,7 @@ spec:
41
47
  services:
42
48
  {% for entity in profile.entities %}
43
49
  {{ entity.name }}_svc:
44
- class: "services.{{ entity.name | capitalize }}Service"
50
+ class: "{{ output_module }}.services.{{ entity.name }}_service.{{ entity.name | capitalize }}Service"
51
+ config:
52
+ db: "entity_store"
45
53
  {% endfor %}
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "hexdag-forge"
3
- version = "0.2.0.dev4"
3
+ version = "0.2.0.dev5"
4
4
  description = "Generate operational systems from prompts — code generator powered by hexDAG"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -94,11 +94,14 @@ class TestAssemble:
94
94
 
95
95
  def test_generates_services(self, tmp_path: Path):
96
96
  assemble(_freight_profile(), tmp_path)
97
- services = list((tmp_path / "services").glob("*.py"))
98
- assert len(services) == 2
99
- names = {s.name for s in services}
97
+ services_dir = tmp_path / "services"
98
+ service_files = [f for f in services_dir.glob("*.py") if f.name != "__init__.py"]
99
+ assert len(service_files) == 2
100
+ names = {s.name for s in service_files}
100
101
  assert "load_service.py" in names
101
102
  assert "carrier_service.py" in names
103
+ # Services dir is a proper Python package
104
+ assert (services_dir / "__init__.py").exists()
102
105
 
103
106
  def test_services_have_valid_python(self, tmp_path: Path):
104
107
  assemble(_freight_profile(), tmp_path)
@@ -211,6 +214,48 @@ class TestAssemble:
211
214
  migrations = list(migrations_dir.glob("0001_*.py"))
212
215
  assert len(migrations) == 1
213
216
 
217
+ def test_system_yaml_has_ports(self, tmp_path: Path):
218
+ assemble(_freight_profile(), tmp_path)
219
+ content = (tmp_path / "system.yaml").read_text()
220
+ assert "ports:" in content
221
+ assert "entity_store:" in content
222
+ assert "hexdag_forge.runtime.django_store.DjangoEntityStore" in content
223
+ assert "app_label:" in content
224
+
225
+ def test_system_yaml_services_reference_entity_store(self, tmp_path: Path):
226
+ assemble(_freight_profile(), tmp_path)
227
+ content = (tmp_path / "system.yaml").read_text()
228
+ assert 'db: "entity_store"' in content
229
+
230
+ def test_system_yaml_service_paths_contain_output_module(self, tmp_path: Path):
231
+ output = tmp_path / "my_app"
232
+ assemble(_freight_profile(), output)
233
+ content = (output / "system.yaml").read_text()
234
+ # Service class paths should be absolute: output_module.services.entity_service.Class
235
+ assert "my_app.services.load_service.LoadService" in content
236
+ assert "my_app.services.carrier_service.CarrierService" in content
237
+
238
+ def test_pipeline_uses_lifecycle_input(self, tmp_path: Path):
239
+ assemble(_freight_profile(), tmp_path)
240
+ content = (tmp_path / "pipelines" / "load_lifecycle.yaml").read_text()
241
+ # Should reference $input.entity_id (from lifecycle runner), not $input.load_id
242
+ assert "$input.entity_id" in content
243
+ assert "$input.to_state" in content
244
+ assert "$input.reason" in content
245
+
246
+ def test_pipeline_references_system_service(self, tmp_path: Path):
247
+ assemble(_freight_profile(), tmp_path)
248
+ content = (tmp_path / "pipelines" / "load_lifecycle.yaml").read_text()
249
+ # Pipeline should reference load_svc (matching system.yaml service name)
250
+ assert "load_svc" in content
251
+
252
+ def test_output_module_override(self, tmp_path: Path):
253
+ profile = _freight_profile()
254
+ profile = profile.model_copy(update={"output_module": "custom_module"})
255
+ assemble(profile, tmp_path)
256
+ content = (tmp_path / "system.yaml").read_text()
257
+ assert "custom_module.services.load_service.LoadService" in content
258
+
214
259
 
215
260
  class TestIncrementalMigration:
216
261
  """Verify that calling assemble() twice produces incremental migrations."""
File without changes
@@ -0,0 +1,129 @@
1
+ """Tests for InMemoryEntityStore."""
2
+
3
+ import pytest
4
+
5
+ from hexdag_forge.runtime.memory_store import InMemoryEntityStore
6
+
7
+
8
+ @pytest.fixture
9
+ def store() -> InMemoryEntityStore:
10
+ return InMemoryEntityStore()
11
+
12
+
13
+ class TestCreate:
14
+ async def test_returns_record_with_id_and_status(self, store: InMemoryEntityStore):
15
+ record = await store.create("load", {"origin": "NYC", "destination": "LA"}, "POSTED")
16
+ assert "id" in record
17
+ assert record["status"] == "POSTED"
18
+ assert record["origin"] == "NYC"
19
+
20
+ async def test_generates_unique_ids(self, store: InMemoryEntityStore):
21
+ r1 = await store.create("load", {}, "POSTED")
22
+ r2 = await store.create("load", {}, "POSTED")
23
+ assert r1["id"] != r2["id"]
24
+
25
+ async def test_separate_entity_types(self, store: InMemoryEntityStore):
26
+ await store.create("load", {"x": 1}, "POSTED")
27
+ await store.create("carrier", {"y": 2}, "PENDING")
28
+ loads = await store.list_all("load")
29
+ carriers = await store.list_all("carrier")
30
+ assert len(loads) == 1
31
+ assert len(carriers) == 1
32
+
33
+
34
+ class TestGet:
35
+ async def test_retrieves_existing_record(self, store: InMemoryEntityStore):
36
+ created = await store.create("load", {"origin": "NYC"}, "POSTED")
37
+ fetched = await store.get("load", created["id"])
38
+ assert fetched["origin"] == "NYC"
39
+ assert fetched["id"] == created["id"]
40
+
41
+ async def test_raises_key_error_for_missing(self, store: InMemoryEntityStore):
42
+ with pytest.raises(KeyError, match="not found"):
43
+ await store.get("load", "nonexistent")
44
+
45
+ async def test_returns_copy(self, store: InMemoryEntityStore):
46
+ created = await store.create("load", {"origin": "NYC"}, "POSTED")
47
+ fetched = await store.get("load", created["id"])
48
+ fetched["origin"] = "MUTATED"
49
+ refetched = await store.get("load", created["id"])
50
+ assert refetched["origin"] == "NYC"
51
+
52
+
53
+ class TestListAll:
54
+ async def test_lists_all_entities(self, store: InMemoryEntityStore):
55
+ await store.create("load", {}, "POSTED")
56
+ await store.create("load", {}, "COVERED")
57
+ results = await store.list_all("load")
58
+ assert len(results) == 2
59
+
60
+ async def test_filters_by_status(self, store: InMemoryEntityStore):
61
+ await store.create("load", {}, "POSTED")
62
+ await store.create("load", {}, "COVERED")
63
+ await store.create("load", {}, "POSTED")
64
+ results = await store.list_all("load", status="POSTED")
65
+ assert len(results) == 2
66
+ assert all(r["status"] == "POSTED" for r in results)
67
+
68
+ async def test_respects_limit(self, store: InMemoryEntityStore):
69
+ for _ in range(10):
70
+ await store.create("load", {}, "POSTED")
71
+ results = await store.list_all("load", limit=3)
72
+ assert len(results) == 3
73
+
74
+ async def test_empty_entity_type(self, store: InMemoryEntityStore):
75
+ results = await store.list_all("nonexistent")
76
+ assert results == []
77
+
78
+
79
+ class TestUpdate:
80
+ async def test_updates_fields(self, store: InMemoryEntityStore):
81
+ created = await store.create("load", {"origin": "NYC", "weight": 100}, "POSTED")
82
+ updated = await store.update("load", created["id"], {"weight": 200})
83
+ assert updated["weight"] == 200
84
+ assert updated["origin"] == "NYC"
85
+
86
+ async def test_raises_for_missing(self, store: InMemoryEntityStore):
87
+ with pytest.raises(KeyError, match="not found"):
88
+ await store.update("load", "nonexistent", {"x": 1})
89
+
90
+
91
+ class TestDelete:
92
+ async def test_deletes_record(self, store: InMemoryEntityStore):
93
+ created = await store.create("load", {}, "POSTED")
94
+ result = await store.delete("load", created["id"])
95
+ assert result["deleted"] is True
96
+ with pytest.raises(KeyError):
97
+ await store.get("load", created["id"])
98
+
99
+ async def test_raises_for_missing(self, store: InMemoryEntityStore):
100
+ with pytest.raises(KeyError, match="not found"):
101
+ await store.delete("load", "nonexistent")
102
+
103
+
104
+ class TestTransition:
105
+ async def test_transitions_state(self, store: InMemoryEntityStore):
106
+ created = await store.create("load", {}, "POSTED")
107
+ result = await store.transition("load", created["id"], "COVERED", reason="matched")
108
+ assert result["from_state"] == "POSTED"
109
+ assert result["to_state"] == "COVERED"
110
+ assert result["reason"] == "matched"
111
+ assert result["status"] == "COVERED"
112
+
113
+ async def test_persists_transition(self, store: InMemoryEntityStore):
114
+ created = await store.create("load", {}, "POSTED")
115
+ await store.transition("load", created["id"], "COVERED")
116
+ fetched = await store.get("load", created["id"])
117
+ assert fetched["status"] == "COVERED"
118
+
119
+ async def test_raises_for_missing(self, store: InMemoryEntityStore):
120
+ with pytest.raises(KeyError, match="not found"):
121
+ await store.transition("load", "nonexistent", "COVERED")
122
+
123
+
124
+ class TestProtocolCompliance:
125
+ def test_implements_supports_entity_store(self):
126
+ from hexdag_forge.runtime.entity_store import SupportsEntityStore
127
+
128
+ store = InMemoryEntityStore()
129
+ assert isinstance(store, SupportsEntityStore)
@@ -1,43 +0,0 @@
1
- {% extends "base.html" %}
2
- {% block title %}Chat — [[ profile.business_name ]]{% endblock %}
3
- {% block heading %}<h2>Chat</h2>{% endblock %}
4
- {% block content %}
5
- <div style="display: flex; gap: 1rem; height: 70vh;">
6
- <aside style="width: 200px; overflow-y: auto; border-right: 1px solid var(--pico-muted-border-color); padding-right: 1rem;">
7
- <form method="post" action="/chat/new/">
8
- {% csrf_token %}
9
- <button type="submit" style="width: 100%; margin-bottom: 0.5rem;">+ New Chat</button>
10
- </form>
11
- <ul style="list-style: none; padding: 0;">
12
- {% for conv in conversations %}
13
- <li style="margin-bottom: 0.25rem;">
14
- <a href="/chat/{{ conv.pk }}/"
15
- {% if conversation and conversation.pk == conv.pk %}style="font-weight: bold;"{% endif %}>
16
- {{ conv.title|truncatechars:30 }}
17
- </a>
18
- </li>
19
- {% empty %}
20
- <li><small>No conversations yet</small></li>
21
- {% endfor %}
22
- </ul>
23
- </aside>
24
- <div style="flex: 1; display: flex; flex-direction: column;">
25
- {% if conversation %}
26
- <div id="messages" style="flex: 1; overflow-y: auto; padding: 0.5rem;">
27
- {% include "chat/_messages.html" %}
28
- </div>
29
- <form hx-post="/chat/send/"
30
- hx-target="#messages"
31
- hx-swap="innerHTML"
32
- style="display: flex; gap: 0.5rem; margin-top: 0.5rem;">
33
- {% csrf_token %}
34
- <input type="hidden" name="conversation_id" value="{{ conversation.pk }}">
35
- <textarea name="content" rows="1" placeholder="Type a message..." style="flex: 1;"></textarea>
36
- <button type="submit">Send</button>
37
- </form>
38
- {% else %}
39
- <p>Select a conversation or start a new one.</p>
40
- {% endif %}
41
- </div>
42
- </div>
43
- {% endblock %}
@@ -1,29 +0,0 @@
1
- {% extends "base.html" %}
2
- {% block title %}Chat — [[ profile.business_name ]]{% endblock %}
3
- {% block heading %}<h2>Chat</h2>{% endblock %}
4
- {% block content %}
5
- <form method="post" action="/chat/new/" style="margin-bottom: 1rem;">
6
- {% csrf_token %}
7
- <button type="submit">+ New Conversation</button>
8
- </form>
9
- {% if conversations %}
10
- <table>
11
- <thead>
12
- <tr>
13
- <th>Conversation</th>
14
- <th>Updated</th>
15
- </tr>
16
- </thead>
17
- <tbody>
18
- {% for conv in conversations %}
19
- <tr>
20
- <td><a href="/chat/{{ conv.pk }}/">{{ conv.title }}</a></td>
21
- <td>{{ conv.updated_at|date:"M d, H:i" }}</td>
22
- </tr>
23
- {% endfor %}
24
- </tbody>
25
- </table>
26
- {% else %}
27
- <p>No conversations yet. Start a new one!</p>
28
- {% endif %}
29
- {% endblock %}
@@ -1,8 +0,0 @@
1
- {% for msg in messages %}
2
- <div style="margin-bottom: 0.5rem; padding: 0.5rem; border-radius: 8px; {% if msg.sender == 'user' %}background: var(--pico-primary-focus); text-align: right;{% else %}background: var(--pico-muted-border-color);{% endif %}">
3
- <small><strong>{{ msg.sender|title }}</strong> &middot; {{ msg.created_at|date:"H:i" }}</small>
4
- <p style="margin: 0.25rem 0 0;">{{ msg.content }}</p>
5
- </div>
6
- {% empty %}
7
- <p><small>No messages yet. Send one to get started.</small></p>
8
- {% endfor %}