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.
- {hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/PKG-INFO +1 -1
- {hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/hexdag_forge/cli.py +32 -0
- {hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/hexdag_forge/domain/business_profile.py +1 -0
- {hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/hexdag_forge/generator/assembler.py +18 -2
- {hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/hexdag_forge/generator/migration_generator.py +45 -0
- {hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/hexdag_forge/generator/system_generator.py +12 -2
- hexdag_forge-0.2.0.dev5/hexdag_forge/runtime/__init__.py +1 -0
- hexdag_forge-0.2.0.dev5/hexdag_forge/runtime/django_store.py +124 -0
- hexdag_forge-0.2.0.dev5/hexdag_forge/runtime/entity_store.py +59 -0
- hexdag_forge-0.2.0.dev5/hexdag_forge/runtime/memory_store.py +95 -0
- {hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/hexdag_forge/templates/chat_pipeline.yaml.j2 +7 -1
- {hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/hexdag_forge/templates/django_base.html.j2 +6 -2
- hexdag_forge-0.2.0.dev5/hexdag_forge/templates/django_chat.html.j2 +119 -0
- hexdag_forge-0.2.0.dev5/hexdag_forge/templates/django_chat_conversations.html.j2 +27 -0
- hexdag_forge-0.2.0.dev5/hexdag_forge/templates/django_chat_messages_partial.html.j2 +10 -0
- {hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/hexdag_forge/templates/pipeline.yaml.j2 +6 -10
- {hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/hexdag_forge/templates/system.yaml.j2 +9 -1
- {hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/pyproject.toml +1 -1
- {hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/tests/test_generator/test_assembler.py +48 -3
- hexdag_forge-0.2.0.dev5/tests/test_runtime/__init__.py +0 -0
- hexdag_forge-0.2.0.dev5/tests/test_runtime/test_memory_store.py +129 -0
- hexdag_forge-0.2.0.dev4/hexdag_forge/templates/django_chat.html.j2 +0 -43
- hexdag_forge-0.2.0.dev4/hexdag_forge/templates/django_chat_conversations.html.j2 +0 -29
- hexdag_forge-0.2.0.dev4/hexdag_forge/templates/django_chat_messages_partial.html.j2 +0 -8
- {hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/.gitignore +0 -0
- {hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/LICENSE +0 -0
- {hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/README.md +0 -0
- {hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/hexdag_forge/__init__.py +0 -0
- {hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/hexdag_forge/domain/__init__.py +0 -0
- {hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/hexdag_forge/domain/enums.py +0 -0
- {hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/hexdag_forge/domain/generated_system.py +0 -0
- {hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/hexdag_forge/domain/profile_diff.py +0 -0
- {hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/hexdag_forge/generator/__init__.py +0 -0
- {hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/hexdag_forge/generator/diff.py +0 -0
- {hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/hexdag_forge/generator/django_generator.py +0 -0
- {hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/hexdag_forge/generator/entity_generator.py +0 -0
- {hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/hexdag_forge/generator/field_mapping.py +0 -0
- {hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/hexdag_forge/generator/pipeline_generator.py +0 -0
- {hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/hexdag_forge/generator/service_generator.py +0 -0
- {hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/hexdag_forge/generator/template_engine.py +0 -0
- {hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/hexdag_forge/mcp/__init__.py +0 -0
- {hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/hexdag_forge/mcp/__main__.py +0 -0
- {hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/hexdag_forge/mcp/server.py +0 -0
- {hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/hexdag_forge/skills/forge.md +0 -0
- {hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/hexdag_forge/skills/forge_entity.md +0 -0
- {hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/hexdag_forge/skills/forge_implement.md +0 -0
- {hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/hexdag_forge/skills/forge_test.md +0 -0
- {hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/hexdag_forge/templates/django_admin.py.j2 +0 -0
- {hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/hexdag_forge/templates/django_apps.py.j2 +0 -0
- {hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/hexdag_forge/templates/django_chat_agent.py.j2 +0 -0
- {hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/hexdag_forge/templates/django_dashboard.html.j2 +0 -0
- {hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/hexdag_forge/templates/django_entity_confirm_delete.html.j2 +0 -0
- {hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/hexdag_forge/templates/django_entity_detail.html.j2 +0 -0
- {hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/hexdag_forge/templates/django_entity_detail_partial.html.j2 +0 -0
- {hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/hexdag_forge/templates/django_entity_form.html.j2 +0 -0
- {hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/hexdag_forge/templates/django_entity_form_modal.html.j2 +0 -0
- {hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/hexdag_forge/templates/django_entity_list.html.j2 +0 -0
- {hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/hexdag_forge/templates/django_entity_list_partial.html.j2 +0 -0
- {hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/hexdag_forge/templates/django_forms.py.j2 +0 -0
- {hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/hexdag_forge/templates/django_logged_out.html.j2 +0 -0
- {hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/hexdag_forge/templates/django_login.html.j2 +0 -0
- {hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/hexdag_forge/templates/django_manage.py.j2 +0 -0
- {hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/hexdag_forge/templates/django_migration.py.j2 +0 -0
- {hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/hexdag_forge/templates/django_model.py.j2 +0 -0
- {hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/hexdag_forge/templates/django_register.html.j2 +0 -0
- {hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/hexdag_forge/templates/django_settings.py.j2 +0 -0
- {hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/hexdag_forge/templates/django_templatetags.py.j2 +0 -0
- {hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/hexdag_forge/templates/django_urls.py.j2 +0 -0
- {hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/hexdag_forge/templates/django_views.py.j2 +0 -0
- {hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/hexdag_forge/templates/service.py.j2 +0 -0
- {hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/hexdag_forge/templates/state_machines.py.j2 +0 -0
- {hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/tests/__init__.py +0 -0
- {hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/tests/test_domain.py +0 -0
- {hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/tests/test_generator/__init__.py +0 -0
- {hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/tests/test_generator/test_diff.py +0 -0
- {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.
|
|
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",
|
|
@@ -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
|
|
{hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/hexdag_forge/generator/migration_generator.py
RENAMED
|
@@ -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
|
+
]
|
{hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/hexdag_forge/generator/system_generator.py
RENAMED
|
@@ -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(
|
|
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
|
+
}
|
{hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/hexdag_forge/templates/chat_pipeline.yaml.j2
RENAMED
|
@@ -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: {
|
|
36
|
+
User message: {{ '{{message}}' }}
|
|
31
37
|
config:
|
|
32
38
|
max_steps: 10
|
|
33
39
|
dependencies: []
|
{hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/hexdag_forge/templates/django_base.html.j2
RENAMED
|
@@ -107,14 +107,18 @@
|
|
|
107
107
|
<li><a href="/">■ 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
|
-
|
|
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">✉</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:
|
|
13
|
+
service: {{ entity.name }}_svc
|
|
18
14
|
method: get_{{ entity.name }}
|
|
19
15
|
input_mapping:
|
|
20
|
-
{{ entity.name }}_id: "$input.
|
|
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.
|
|
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:
|
|
31
|
+
service: {{ entity.name }}_svc
|
|
36
32
|
method: transition_{{ entity.name }}
|
|
37
33
|
input_mapping:
|
|
38
|
-
{{ entity.name }}_id: "$input.
|
|
39
|
-
to_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 %}
|
|
@@ -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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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> · {{ 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 %}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/hexdag_forge/generator/django_generator.py
RENAMED
|
File without changes
|
{hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/hexdag_forge/generator/entity_generator.py
RENAMED
|
File without changes
|
|
File without changes
|
{hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/hexdag_forge/generator/pipeline_generator.py
RENAMED
|
File without changes
|
{hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/hexdag_forge/generator/service_generator.py
RENAMED
|
File without changes
|
{hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/hexdag_forge/generator/template_engine.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/hexdag_forge/templates/django_admin.py.j2
RENAMED
|
File without changes
|
{hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/hexdag_forge/templates/django_apps.py.j2
RENAMED
|
File without changes
|
{hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/hexdag_forge/templates/django_chat_agent.py.j2
RENAMED
|
File without changes
|
{hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/hexdag_forge/templates/django_dashboard.html.j2
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/hexdag_forge/templates/django_forms.py.j2
RENAMED
|
File without changes
|
{hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/hexdag_forge/templates/django_logged_out.html.j2
RENAMED
|
File without changes
|
{hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/hexdag_forge/templates/django_login.html.j2
RENAMED
|
File without changes
|
{hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/hexdag_forge/templates/django_manage.py.j2
RENAMED
|
File without changes
|
{hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/hexdag_forge/templates/django_migration.py.j2
RENAMED
|
File without changes
|
{hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/hexdag_forge/templates/django_model.py.j2
RENAMED
|
File without changes
|
{hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/hexdag_forge/templates/django_register.html.j2
RENAMED
|
File without changes
|
{hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/hexdag_forge/templates/django_settings.py.j2
RENAMED
|
File without changes
|
{hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/hexdag_forge/templates/django_templatetags.py.j2
RENAMED
|
File without changes
|
{hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/hexdag_forge/templates/django_urls.py.j2
RENAMED
|
File without changes
|
{hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/hexdag_forge/templates/django_views.py.j2
RENAMED
|
File without changes
|
|
File without changes
|
{hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/hexdag_forge/templates/state_machines.py.j2
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{hexdag_forge-0.2.0.dev4 → hexdag_forge-0.2.0.dev5}/tests/test_generator/test_migration_generator.py
RENAMED
|
File without changes
|