enlace 0.0.2__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.
- enlace-0.0.2/.claude/CLAUDE.md +113 -0
- enlace-0.0.2/.claude/skills/enlace/SKILL.md +249 -0
- enlace-0.0.2/.claude/skills/enlace/evals/evals.json +23 -0
- enlace-0.0.2/.claude/skills/enlace-dev/SKILL.md +169 -0
- enlace-0.0.2/.claude/skills/enlace-dev/evals/evals.json +17 -0
- enlace-0.0.2/.claude/skills/enlace-diagnose/SKILL.md +275 -0
- enlace-0.0.2/.github/workflows/ci.yml +256 -0
- enlace-0.0.2/.gitignore +13 -0
- enlace-0.0.2/PKG-INFO +239 -0
- enlace-0.0.2/README.md +220 -0
- enlace-0.0.2/enlace/__init__.py +35 -0
- enlace-0.0.2/enlace/__main__.py +242 -0
- enlace-0.0.2/enlace/base.py +165 -0
- enlace-0.0.2/enlace/compose.py +180 -0
- enlace-0.0.2/enlace/data/skills/enlace/SKILL.md +249 -0
- enlace-0.0.2/enlace/data/skills/enlace/evals/evals.json +23 -0
- enlace-0.0.2/enlace/data/skills/enlace-dev/SKILL.md +169 -0
- enlace-0.0.2/enlace/data/skills/enlace-dev/evals/evals.json +17 -0
- enlace-0.0.2/enlace/data/skills/enlace-diagnose/SKILL.md +275 -0
- enlace-0.0.2/enlace/diagnose.py +1102 -0
- enlace-0.0.2/enlace/discover.py +286 -0
- enlace-0.0.2/enlace/serve.py +123 -0
- enlace-0.0.2/enlace/tests/__init__.py +0 -0
- enlace-0.0.2/enlace/tests/conftest.py +98 -0
- enlace-0.0.2/enlace/tests/test_compose.py +116 -0
- enlace-0.0.2/enlace/tests/test_discover.py +239 -0
- enlace-0.0.2/enlace/util.py +38 -0
- enlace-0.0.2/misc/docs/asgi_composition__ASGI_composition_and_dynamic_sub_application_mounting_in_FastAPI_or_Starlette.md +520 -0
- enlace-0.0.2/misc/docs/auth_cross_cutting__Authentication_and_Authorization_as_Cross_Cutting_Concerns.md +669 -0
- enlace-0.0.2/misc/docs/convention_over_configuration__Convention_over_Configuration_in_Python.md +761 -0
- enlace-0.0.2/misc/docs/deployment_observability__Deployment_process_management_hot_reload_and_observability_for_a_personal_app_platform.md +755 -0
- enlace-0.0.2/misc/docs/design_principles__Zero_coupling_and_standalone_preservation_in_multi_app_composition.md +206 -0
- enlace-0.0.2/misc/docs/enlace_spec.md +782 -0
- enlace-0.0.2/misc/docs/frontend_serving__Multi_app_frontend_serving_under_a_single_domain.md +439 -0
- enlace-0.0.2/misc/docs/user_data_persistence__Multi_app_user_data_persistence_with_repository_abstractions.md +341 -0
- enlace-0.0.2/pyproject.toml +58 -0
- enlace-0.0.2/tests/__init__.py +0 -0
- enlace-0.0.2/tests/test_platform.py +98 -0
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# enlace — Agent Instructions
|
|
2
|
+
|
|
3
|
+
## Core Principles
|
|
4
|
+
|
|
5
|
+
These two principles govern ALL changes to enlace. Every PR, feature, and
|
|
6
|
+
suggestion must respect both. They are in tension — the design challenge is
|
|
7
|
+
balancing them.
|
|
8
|
+
|
|
9
|
+
### 1. Apps should not need to change
|
|
10
|
+
|
|
11
|
+
enlace wraps apps from the outside. Apps don't import enlace, don't depend
|
|
12
|
+
on it, and don't know it exists. All aggregation logic (discovery, routing,
|
|
13
|
+
CORS, static serving) lives in enlace, not in the app.
|
|
14
|
+
|
|
15
|
+
When enlace encounters an app that's hard to mount:
|
|
16
|
+
- **First**: solve it on the enlace side (app.toml config, env vars, middleware)
|
|
17
|
+
- **Second**: suggest a minimal app change that preserves standalone operation
|
|
18
|
+
- **Last resort**: suggest an app change that breaks standalone — but flag it explicitly
|
|
19
|
+
|
|
20
|
+
### 2. Enlaced apps must still work alone
|
|
21
|
+
|
|
22
|
+
An app that works standalone today must still work standalone after being
|
|
23
|
+
enlaced. When we suggest changes to an app, those changes MUST preserve
|
|
24
|
+
the app's ability to run independently.
|
|
25
|
+
|
|
26
|
+
The pattern: **env-var with current value as default**.
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
# GOOD: works standalone AND under enlace
|
|
30
|
+
import os
|
|
31
|
+
if not os.environ.get('ENLACE_MANAGED'):
|
|
32
|
+
app.add_middleware(CORSMiddleware, ...)
|
|
33
|
+
|
|
34
|
+
# BAD: breaks standalone
|
|
35
|
+
# (CORSMiddleware deleted entirely)
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
```typescript
|
|
39
|
+
// GOOD: standalone uses localhost, enlace overrides at build time
|
|
40
|
+
const API_BASE = process.env.NEXT_PUBLIC_API_BASE || "http://localhost:8000/api";
|
|
41
|
+
|
|
42
|
+
// BAD: breaks standalone
|
|
43
|
+
const API_BASE = "/api/s_conditions";
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
### Zero coupling
|
|
47
|
+
|
|
48
|
+
Apps do not import enlace. The dependency graph:
|
|
49
|
+
- enlace depends on FastAPI, Uvicorn, Pydantic, argh
|
|
50
|
+
- Apps depend on their own domain libs (FastAPI, numpy, etc.)
|
|
51
|
+
- There is NO dependency from apps to enlace
|
|
52
|
+
|
|
53
|
+
enlace provides services to apps via:
|
|
54
|
+
- ASGI scope injection (auth, store) — apps read `request.state.store`, not `import enlace.stores`
|
|
55
|
+
- Environment variables (`ENLACE_MANAGED`) — apps can condition on this but don't have to
|
|
56
|
+
- Convention (filesystem layout, app.toml) — external to app code
|
|
57
|
+
|
|
58
|
+
## Architecture
|
|
59
|
+
|
|
60
|
+
Read `misc/docs/enlace_spec.md` for the full architecture and design rationale.
|
|
61
|
+
|
|
62
|
+
```
|
|
63
|
+
enlace/
|
|
64
|
+
├── base.py # Pydantic models: AppConfig, PlatformConfig, ConventionsConfig
|
|
65
|
+
├── util.py # Pure helpers: derive_display_name, derive_route_prefix, is_skippable
|
|
66
|
+
├── discover.py # ConventionDiscoverer: walks apps/, detects types, loads TOML
|
|
67
|
+
├── compose.py # build_backend(): mounts sub-apps, cascade_lifespan, sets ENLACE_MANAGED
|
|
68
|
+
├── diagnose.py # diagnose_app(): scan an app dir for enlace compatibility issues
|
|
69
|
+
├── serve.py # Uvicorn subprocess orchestration, signal forwarding
|
|
70
|
+
├── __main__.py # CLI via argh.dispatch_commands
|
|
71
|
+
├── __init__.py # Public API facade
|
|
72
|
+
└── tests/ # Unit tests (test_discover.py, test_compose.py)
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
**Data flow:** `PlatformConfig.from_toml()` → `ConventionDiscoverer.discover()` →
|
|
76
|
+
`config.check_conflicts()` → `build_backend(config)` → `uvicorn --factory`
|
|
77
|
+
|
|
78
|
+
## Before Making Changes
|
|
79
|
+
|
|
80
|
+
- Run `enlace show-config` to understand current state
|
|
81
|
+
- Run `enlace check` to validate config before and after changes
|
|
82
|
+
|
|
83
|
+
## Critical Rules
|
|
84
|
+
|
|
85
|
+
- **NEVER use `BaseHTTPMiddleware`** — it has terminal bugs (exception swallowing, ContextVar corruption). Use pure ASGI middleware (three-callable pattern) only.
|
|
86
|
+
- **Mount on `FastAPI()` directly**, never on `APIRouter` — known framework bug.
|
|
87
|
+
- **Discovery must never silently swallow ImportErrors** — distinguish "module doesn't exist" from "module has broken import".
|
|
88
|
+
- **All middleware must be pure ASGI** (scope, receive, send pattern).
|
|
89
|
+
- **Conflict detection is fail-fast** — report ALL conflicts, don't stop at first.
|
|
90
|
+
- **CORS on parent only** — sub-apps must not add their own CORSMiddleware. If they do, enlace still works (MEDIUM issue, not a blocker), but the diagnostic flags it.
|
|
91
|
+
- **`ENLACE_MANAGED=1`** — set by `build_backend()` so sub-apps can condition on it.
|
|
92
|
+
- **Suggestions to app developers must preserve standalone operation** — use the env-var-with-default pattern, never suggest changes that break the app's ability to run alone.
|
|
93
|
+
|
|
94
|
+
## Research Docs
|
|
95
|
+
|
|
96
|
+
Consult these before modifying subsystems:
|
|
97
|
+
|
|
98
|
+
| Subsystem | Document |
|
|
99
|
+
|-----------|----------|
|
|
100
|
+
| App mounting, middleware | `misc/docs/asgi_composition__*.md` |
|
|
101
|
+
| Auth middleware | `misc/docs/auth_cross_cutting__*.md` |
|
|
102
|
+
| Discovery, config | `misc/docs/convention_over_configuration__*.md` |
|
|
103
|
+
| Deployment, logging | `misc/docs/deployment_observability__*.md` |
|
|
104
|
+
| Frontend serving | `misc/docs/frontend_serving__*.md` |
|
|
105
|
+
| Data persistence | `misc/docs/user_data_persistence__*.md` |
|
|
106
|
+
| Design principles | `misc/docs/design_principles__*.md` |
|
|
107
|
+
|
|
108
|
+
## Testing
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
pytest enlace/tests/ # Unit tests
|
|
112
|
+
pytest tests/ # Integration tests
|
|
113
|
+
```
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: enlace
|
|
3
|
+
description: >
|
|
4
|
+
Use when working with the enlace multi-app platform — creating apps, configuring
|
|
5
|
+
platform.toml or app.toml, diagnosing discovery issues, understanding conventions,
|
|
6
|
+
or serving/deploying apps. Triggers on: enlace CLI commands, apps/ directory
|
|
7
|
+
structures, platform.toml/app.toml files, multi-app ASGI composition, or when the
|
|
8
|
+
user mentions enlace, app discovery, app mounting, or personal app platform.
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
# enlace — Multi-App Platform
|
|
12
|
+
|
|
13
|
+
enlace discovers Python modules and React apps in a directory, mounts them as
|
|
14
|
+
sub-applications under a single ASGI server, and serves them.
|
|
15
|
+
|
|
16
|
+
**enlace is not a framework.** Apps don't import it, don't depend on it, and
|
|
17
|
+
don't know it exists. You write a standard FastAPI app (or plain Python
|
|
18
|
+
functions with type hints). enlace discovers it from the outside, mounts it
|
|
19
|
+
alongside other apps, and serves them all — without touching your code.
|
|
20
|
+
|
|
21
|
+
**Two principles:**
|
|
22
|
+
1. **Apps should not need to change** — all aggregation logic lives in enlace.
|
|
23
|
+
When an app is hard to mount, prefer enlace-side config (app.toml, env vars)
|
|
24
|
+
over app code changes.
|
|
25
|
+
2. **Enlaced apps must still work alone** — if we do suggest changes, they must
|
|
26
|
+
preserve standalone operation. Pattern: env-var with current value as default.
|
|
27
|
+
|
|
28
|
+
## Core Concept
|
|
29
|
+
|
|
30
|
+
Drop files in `apps/`, enlace finds and serves them:
|
|
31
|
+
|
|
32
|
+
```
|
|
33
|
+
apps/
|
|
34
|
+
├── my_tool/
|
|
35
|
+
│ └── server.py # has `app = FastAPI()` → served at /api/my_tool
|
|
36
|
+
├── dashboard/
|
|
37
|
+
│ ├── server.py # backend
|
|
38
|
+
│ └── frontend/
|
|
39
|
+
│ └── index.html # SPA assets
|
|
40
|
+
├── calculator/
|
|
41
|
+
│ └── server.py # has typed functions, no `app` → auto-wrapped
|
|
42
|
+
└── blog/
|
|
43
|
+
└── frontend/
|
|
44
|
+
└── index.html # frontend-only, no backend
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Everything enlace infers is inspectable (`enlace show-config`) and overridable
|
|
48
|
+
(via TOML or CLI flags).
|
|
49
|
+
|
|
50
|
+
## CLI Commands
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
enlace serve # Start backend (dev mode, hot reload)
|
|
54
|
+
enlace serve --mode prod # Production mode (2 workers)
|
|
55
|
+
enlace serve --port 9000 # Custom port
|
|
56
|
+
enlace serve --app-dirs "/a,/b" # Serve apps from specific directories
|
|
57
|
+
enlace show-config # Resolved config with provenance
|
|
58
|
+
enlace show-config --json # Machine-readable
|
|
59
|
+
enlace show-config --verbose # Show where each value came from
|
|
60
|
+
enlace check # Validate config, check route conflicts
|
|
61
|
+
enlace list-apps # Table: name, route, type, access
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Creating an App
|
|
65
|
+
|
|
66
|
+
### Standalone ASGI App (most common)
|
|
67
|
+
|
|
68
|
+
Create `apps/{name}/server.py` with an `app` attribute:
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
# apps/my_tool/server.py
|
|
72
|
+
from fastapi import FastAPI
|
|
73
|
+
|
|
74
|
+
app = FastAPI()
|
|
75
|
+
|
|
76
|
+
@app.get("/hello")
|
|
77
|
+
def hello():
|
|
78
|
+
return {"message": "Hello from my_tool"}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
This gets mounted at `/api/my_tool/`. The `app` attribute name is configurable.
|
|
82
|
+
|
|
83
|
+
### Function Collection (no FastAPI needed)
|
|
84
|
+
|
|
85
|
+
If the entry module has typed public functions but no `app` attribute, enlace
|
|
86
|
+
auto-wraps them as endpoints:
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
# apps/calculator/server.py
|
|
90
|
+
def add(a: int, b: int) -> dict:
|
|
91
|
+
return {"result": a + b}
|
|
92
|
+
|
|
93
|
+
def multiply(a: float, b: float) -> dict:
|
|
94
|
+
return {"result": a * b}
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Functions become POST endpoints: `/api/calculator/add`, `/api/calculator/multiply`.
|
|
98
|
+
Simple-type parameters are query params.
|
|
99
|
+
|
|
100
|
+
### Frontend-Only App
|
|
101
|
+
|
|
102
|
+
Just a `frontend/` directory with `index.html`:
|
|
103
|
+
|
|
104
|
+
```
|
|
105
|
+
apps/blog/
|
|
106
|
+
└── frontend/
|
|
107
|
+
└── index.html
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
No backend mounted. Assets served at `/apps/blog/` (in production via Caddy).
|
|
111
|
+
|
|
112
|
+
## Discovery Conventions
|
|
113
|
+
|
|
114
|
+
| What | Convention | Override key in `app.toml` |
|
|
115
|
+
|------|-----------|---------------------------|
|
|
116
|
+
| Route prefix | `/api/{directory_name}` | `route` |
|
|
117
|
+
| Backend entry | First of `server.py`, `app.py`, `main.py` | `entry_point` |
|
|
118
|
+
| ASGI app object | Attribute named `app` | `app_attr` |
|
|
119
|
+
| Frontend assets | `frontend/` with `index.html` | `frontend_dir` |
|
|
120
|
+
| Display name | Dir name, `_` → space, title-cased | `display_name` |
|
|
121
|
+
| Access level | `local` (default) | `access` |
|
|
122
|
+
| Skip directory | Starts with `_` or `.` | — |
|
|
123
|
+
|
|
124
|
+
## Multi-Source App Discovery
|
|
125
|
+
|
|
126
|
+
enlace can discover apps from multiple locations — you don't need to move
|
|
127
|
+
existing projects into a single `apps/` folder.
|
|
128
|
+
|
|
129
|
+
Two source types:
|
|
130
|
+
- **`apps_dirs`**: directories that CONTAIN app subdirectories (walk children)
|
|
131
|
+
- **`app_dirs`**: individual directories that ARE apps (discover directly)
|
|
132
|
+
|
|
133
|
+
### Enlacing existing projects
|
|
134
|
+
|
|
135
|
+
```toml
|
|
136
|
+
# platform.toml
|
|
137
|
+
[platform]
|
|
138
|
+
apps_dirs = ["apps"]
|
|
139
|
+
app_dirs = [
|
|
140
|
+
"/Users/thor/projects/chord_analyzer",
|
|
141
|
+
"/Users/thor/projects/todo_app",
|
|
142
|
+
]
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
Or via CLI:
|
|
146
|
+
```bash
|
|
147
|
+
enlace serve --app-dirs "/path/to/chord_analyzer,/path/to/todo_app"
|
|
148
|
+
enlace serve --apps-dirs "apps,/path/to/more_apps"
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
Symlinks also work: `ln -s /path/to/my_app apps/my_app` and enlace discovers
|
|
152
|
+
it transparently.
|
|
153
|
+
|
|
154
|
+
App names must be globally unique across all sources. Duplicates are caught
|
|
155
|
+
by `enlace check`.
|
|
156
|
+
|
|
157
|
+
## Configuration
|
|
158
|
+
|
|
159
|
+
### `platform.toml` (global, in project root)
|
|
160
|
+
|
|
161
|
+
```toml
|
|
162
|
+
[platform]
|
|
163
|
+
apps_dirs = ["apps"] # container directories (walk children)
|
|
164
|
+
app_dirs = [] # individual app directories
|
|
165
|
+
domain = "localhost"
|
|
166
|
+
backend_port = 8000
|
|
167
|
+
|
|
168
|
+
[conventions]
|
|
169
|
+
entry_points = ["server.py", "app.py", "main.py"]
|
|
170
|
+
app_attr = "app"
|
|
171
|
+
frontend_dir = "frontend"
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
The legacy `apps_dir = "apps"` (scalar) is still supported for backward compat.
|
|
175
|
+
|
|
176
|
+
### `app.toml` (per-app, in app directory)
|
|
177
|
+
|
|
178
|
+
```toml
|
|
179
|
+
route = "/api/custom-route"
|
|
180
|
+
access = "public"
|
|
181
|
+
display_name = "My Custom App"
|
|
182
|
+
entry_point = "application.py"
|
|
183
|
+
app_attr = "my_app"
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
### Override Precedence (lowest → highest)
|
|
187
|
+
|
|
188
|
+
```
|
|
189
|
+
hardcoded defaults → filesystem conventions → app.toml → platform.toml → env vars → CLI flags
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
Environment variables: `ENLACE_APPS_DIRS`, `ENLACE_APP_DIRS` (pathsep-delimited).
|
|
193
|
+
|
|
194
|
+
## App Types
|
|
195
|
+
|
|
196
|
+
| Type | Detection | Mounting |
|
|
197
|
+
|------|-----------|---------|
|
|
198
|
+
| `asgi_app` | Module has `app` attribute (callable) | `parent.mount(prefix, sub_app)` |
|
|
199
|
+
| `functions` | No `app` attr, has typed public functions | Auto-wrapped as APIRouter |
|
|
200
|
+
| `frontend_only` | No backend entry, has `frontend/index.html` | Static file serving only |
|
|
201
|
+
|
|
202
|
+
## Diagnosing Issues
|
|
203
|
+
|
|
204
|
+
**App not discovered?**
|
|
205
|
+
1. Run `enlace show-config --verbose` — is the app listed?
|
|
206
|
+
2. Check directory isn't prefixed with `_` or `.`
|
|
207
|
+
3. Check entry point file exists (`server.py`, `app.py`, or `main.py`)
|
|
208
|
+
4. Check for import errors — enlace intentionally surfaces them (won't silently skip)
|
|
209
|
+
|
|
210
|
+
**Route conflict?**
|
|
211
|
+
- `enlace check` reports ALL conflicts at once with both app names
|
|
212
|
+
- Fix by changing `route` in one app's `app.toml`
|
|
213
|
+
|
|
214
|
+
**Wrong app type detected?**
|
|
215
|
+
- Run `enlace show-config --verbose` to see detection reason
|
|
216
|
+
- Ensure your `app = FastAPI()` is at module level (not inside a function)
|
|
217
|
+
- For function collections, ensure functions have type annotations
|
|
218
|
+
|
|
219
|
+
## Access Levels
|
|
220
|
+
|
|
221
|
+
| Level | Description |
|
|
222
|
+
|-------|-------------|
|
|
223
|
+
| `local` | Development only (default) |
|
|
224
|
+
| `public` | Open to everyone |
|
|
225
|
+
| `protected:shared` | Single shared password gate |
|
|
226
|
+
| `protected:user` | Per-user accounts |
|
|
227
|
+
|
|
228
|
+
Set in `app.toml` with `access = "protected:shared"` etc.
|
|
229
|
+
|
|
230
|
+
## Workflow
|
|
231
|
+
|
|
232
|
+
```bash
|
|
233
|
+
# 1. Initialize (creates platform.toml + apps/)
|
|
234
|
+
enlace init
|
|
235
|
+
|
|
236
|
+
# 2. Create an app
|
|
237
|
+
mkdir -p apps/my_app
|
|
238
|
+
# Write apps/my_app/server.py
|
|
239
|
+
|
|
240
|
+
# 3. Verify discovery
|
|
241
|
+
enlace show-config
|
|
242
|
+
enlace check
|
|
243
|
+
|
|
244
|
+
# 4. Serve
|
|
245
|
+
enlace serve
|
|
246
|
+
# → http://localhost:8000/api/my_app/
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
Always run `enlace check` after making changes to catch conflicts early.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"skill_name": "enlace",
|
|
3
|
+
"evals": [
|
|
4
|
+
{
|
|
5
|
+
"id": 1,
|
|
6
|
+
"prompt": "I want to add a new app to my enlace platform that exposes a couple of Python functions as API endpoints -- one that takes a text string and returns the word count, and another that reverses the string. I don't want to deal with FastAPI boilerplate, just the functions. Set it up for me in apps/text_utils/",
|
|
7
|
+
"expected_output": "Creates apps/text_utils/server.py with two typed functions (word_count and reverse_text), no FastAPI app object. The agent understands this will be auto-detected as a 'functions' type app and served at /api/text_utils/.",
|
|
8
|
+
"files": []
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
"id": 2,
|
|
12
|
+
"prompt": "I'm getting an error when I run enlace serve -- it says route conflict. I have three apps in my apps directory: todo, notes, and dashboard. The dashboard app has an app.toml that sets route = '/api/notes'. Can you help me figure out what's going on and fix it?",
|
|
13
|
+
"expected_output": "The agent identifies the conflict between the 'notes' app (convention route /api/notes) and 'dashboard' app (overridden to /api/notes via app.toml). Suggests running enlace check, then fixes dashboard's app.toml to use a different route like /api/dashboard or /api/dash.",
|
|
14
|
+
"files": []
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"id": 3,
|
|
18
|
+
"prompt": "I need to set up a new enlace project from scratch. I want two apps: a chord_analyzer with a FastAPI backend and a React frontend, and a simple notes app that's backend-only. The chord_analyzer should be public access and the notes app should be protected with a shared password. Walk me through the whole setup.",
|
|
19
|
+
"expected_output": "The agent creates the full directory structure: platform.toml with conventions, apps/chord_analyzer/server.py with FastAPI app, apps/chord_analyzer/frontend/ placeholder, apps/chord_analyzer/app.toml with access=public, apps/notes/server.py with FastAPI app, apps/notes/app.toml with access=protected:shared. Mentions running enlace show-config to verify.",
|
|
20
|
+
"files": []
|
|
21
|
+
}
|
|
22
|
+
]
|
|
23
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: enlace-dev
|
|
3
|
+
description: >
|
|
4
|
+
Use when developing or modifying the enlace package itself — adding features,
|
|
5
|
+
fixing bugs, extending the ASGI composition, adding middleware, or working on
|
|
6
|
+
any module in the enlace/ source directory. Triggers on: editing enlace source
|
|
7
|
+
files (base.py, discover.py, compose.py, serve.py, auth.py, stores.py),
|
|
8
|
+
implementing spec phases 2-4, or when the user says "add X to enlace" or
|
|
9
|
+
"implement Y in enlace".
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
# Developing enlace
|
|
13
|
+
|
|
14
|
+
enlace is a multi-app ASGI platform. This skill covers the critical rules,
|
|
15
|
+
architecture, and patterns needed to modify the enlace codebase safely.
|
|
16
|
+
|
|
17
|
+
## Design Principles
|
|
18
|
+
|
|
19
|
+
These two principles govern ALL changes. Every feature, fix, and diagnostic
|
|
20
|
+
suggestion must respect both:
|
|
21
|
+
|
|
22
|
+
### 1. Zero coupling — apps don't import enlace
|
|
23
|
+
|
|
24
|
+
enlace wraps apps from the outside. Apps depend on their own domain libs
|
|
25
|
+
(FastAPI, numpy, etc.), never on enlace. enlace provides services via:
|
|
26
|
+
- ASGI scope injection (auth, store) — not imports
|
|
27
|
+
- Environment variables (`ENLACE_MANAGED`) — optional, apps can ignore it
|
|
28
|
+
- Convention (filesystem layout, app.toml) — external to app code
|
|
29
|
+
|
|
30
|
+
When adding features, NEVER create patterns that require apps to
|
|
31
|
+
`import enlace` or depend on enlace-specific APIs.
|
|
32
|
+
|
|
33
|
+
### 2. Preserve standalone operation
|
|
34
|
+
|
|
35
|
+
When enlace (or its diagnostic tool) suggests changes to an app, those
|
|
36
|
+
changes MUST preserve the app's ability to run independently. The pattern:
|
|
37
|
+
**env-var with current value as default**.
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
# GOOD: works standalone AND under enlace
|
|
41
|
+
if not os.environ.get('ENLACE_MANAGED'):
|
|
42
|
+
app.add_middleware(CORSMiddleware, ...)
|
|
43
|
+
|
|
44
|
+
# BAD: breaks standalone
|
|
45
|
+
# (CORSMiddleware deleted entirely)
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
When writing diagnostic messages, fix suggestions, or documentation:
|
|
49
|
+
- Prefer enlace-side solutions (app.toml, build-time env vars) over app changes
|
|
50
|
+
- When app changes are needed, always use the env-var-with-default pattern
|
|
51
|
+
- Flag any suggestion that would break standalone with `breaks_standalone=True`
|
|
52
|
+
|
|
53
|
+
See `misc/docs/design_principles__*.md` for the full rationale.
|
|
54
|
+
|
|
55
|
+
## Architecture
|
|
56
|
+
|
|
57
|
+
```
|
|
58
|
+
enlace/
|
|
59
|
+
├── base.py # Pydantic models: AppConfig, PlatformConfig, ConventionsConfig
|
|
60
|
+
├── util.py # Pure helpers: derive_display_name, derive_route_prefix, is_skippable
|
|
61
|
+
├── discover.py # ConventionDiscoverer: walks apps/, detects types, loads TOML
|
|
62
|
+
├── compose.py # build_backend(): mounts sub-apps, cascade_lifespan
|
|
63
|
+
├── diagnose.py # diagnose_app(): scan an app dir for enlace compatibility issues
|
|
64
|
+
├── serve.py # Uvicorn subprocess orchestration, signal forwarding
|
|
65
|
+
├── __main__.py # CLI via argh.dispatch_commands
|
|
66
|
+
├── __init__.py # Public API facade
|
|
67
|
+
└── tests/ # Unit tests (test_discover.py, test_compose.py)
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
**Data flow:** `PlatformConfig.from_toml()` → `ConventionDiscoverer.discover()` →
|
|
71
|
+
`config.check_conflicts()` → `build_backend(config)` → `uvicorn --factory`
|
|
72
|
+
|
|
73
|
+
## Critical Rules
|
|
74
|
+
|
|
75
|
+
### Never use BaseHTTPMiddleware
|
|
76
|
+
|
|
77
|
+
It has terminal, unfixable bugs: exception swallowing across mounted sub-apps,
|
|
78
|
+
ContextVar propagation corruption, synchronous execution of background tasks.
|
|
79
|
+
The Starlette maintainer has called it unfixable. Always use pure ASGI middleware:
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
# YES: Pure ASGI middleware
|
|
83
|
+
class MyMiddleware:
|
|
84
|
+
def __init__(self, app):
|
|
85
|
+
self.app = app
|
|
86
|
+
|
|
87
|
+
async def __call__(self, scope, receive, send):
|
|
88
|
+
if scope["type"] == "http":
|
|
89
|
+
# your logic here
|
|
90
|
+
pass
|
|
91
|
+
await self.app(scope, receive, send)
|
|
92
|
+
|
|
93
|
+
# NO: BaseHTTPMiddleware — NEVER use this
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### Mount on FastAPI directly, not APIRouter
|
|
97
|
+
|
|
98
|
+
`app.mount()` only works on the top-level FastAPI instance. Mounting on an
|
|
99
|
+
APIRouter silently fails (FastAPI issues #4194, #10180).
|
|
100
|
+
|
|
101
|
+
### Never silently swallow ImportErrors during discovery
|
|
102
|
+
|
|
103
|
+
If a module file exists but fails to import (syntax error, missing dep), that is
|
|
104
|
+
a real error. Propagate it. Only skip directories that have no entry point file.
|
|
105
|
+
This prevents the Celery autodiscover anti-pattern.
|
|
106
|
+
|
|
107
|
+
### Conflict detection: fail-fast, report ALL
|
|
108
|
+
|
|
109
|
+
`check_conflicts()` collects every duplicate route, not just the first. Users
|
|
110
|
+
need to see all conflicts at once to fix them in one pass.
|
|
111
|
+
|
|
112
|
+
### Starlette does NOT cascade lifespan events
|
|
113
|
+
|
|
114
|
+
Mounted sub-apps never receive startup/shutdown. The `cascade_lifespan` context
|
|
115
|
+
manager in compose.py works around this by iterating Mount routes and entering
|
|
116
|
+
their lifespan contexts. Any new mounting logic must preserve this.
|
|
117
|
+
|
|
118
|
+
### CORS on parent only
|
|
119
|
+
|
|
120
|
+
If a sub-app also adds CORS middleware, responses get duplicate headers. All
|
|
121
|
+
cross-cutting middleware (auth, CORS, logging, store injection) goes on the
|
|
122
|
+
parent FastAPI app exclusively.
|
|
123
|
+
|
|
124
|
+
## Research Documents
|
|
125
|
+
|
|
126
|
+
Detailed rationale, code patterns, and pitfalls live in `misc/docs/`:
|
|
127
|
+
|
|
128
|
+
| Module area | Consult |
|
|
129
|
+
|-------------|---------|
|
|
130
|
+
| compose.py, mounting, middleware | `asgi_composition__*.md` |
|
|
131
|
+
| auth.py, sessions, cookies | `auth_cross_cutting__*.md` |
|
|
132
|
+
| discover.py, config, show-config | `convention_over_configuration__*.md` |
|
|
133
|
+
| serve.py, deployment, logging | `deployment_observability__*.md` |
|
|
134
|
+
| frontend.py, SPA serving | `frontend_serving__*.md` |
|
|
135
|
+
| stores.py, PrefixedStore, Mall | `user_data_persistence__*.md` |
|
|
136
|
+
| Design philosophy, coupling, independence | `design_principles__*.md` |
|
|
137
|
+
|
|
138
|
+
Read the relevant doc before modifying its subsystem.
|
|
139
|
+
|
|
140
|
+
## Implementation Phases (from spec)
|
|
141
|
+
|
|
142
|
+
**Phase 1 (done):** Core discovery, composition, CLI, serve
|
|
143
|
+
**Phase 2:** frontend.py (SPAStaticFiles, launcher), auth.py (PlatformAuthMiddleware,
|
|
144
|
+
shared-password login, signed cookies), inject.py (HTML injection), hash-password
|
|
145
|
+
and create-session-secret CLI commands
|
|
146
|
+
**Phase 3:** stores.py (PrefixedStore, Mall, filesystem/SQLite backends),
|
|
147
|
+
StoreInjectionMiddleware, per-user sessions, CSRF, WebSocket origin validation
|
|
148
|
+
**Phase 4:** deploy.py (Caddyfile/systemd generation), structlog middleware,
|
|
149
|
+
analytics injection, `generate` CLI namespace
|
|
150
|
+
|
|
151
|
+
## Testing Pattern
|
|
152
|
+
|
|
153
|
+
```bash
|
|
154
|
+
pytest enlace/tests/ # Unit tests
|
|
155
|
+
pytest tests/ # Integration tests
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
Tests use `tmp_path` fixtures to create temporary app directories. Composition
|
|
159
|
+
tests use `starlette.testclient.TestClient` against the built app. Always test
|
|
160
|
+
both the happy path and error cases (import errors, conflicts, missing files).
|
|
161
|
+
|
|
162
|
+
## Adding a New Module
|
|
163
|
+
|
|
164
|
+
1. Create `enlace/{module}.py`
|
|
165
|
+
2. Add exports to `enlace/__init__.py`
|
|
166
|
+
3. Create `enlace/tests/test_{module}.py`
|
|
167
|
+
4. If it adds CLI commands, register them in `enlace/__main__.py` via
|
|
168
|
+
`argh.dispatch_commands`
|
|
169
|
+
5. Run `enlace check` before and after to verify nothing breaks
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"skill_name": "enlace-dev",
|
|
3
|
+
"evals": [
|
|
4
|
+
{
|
|
5
|
+
"id": 1,
|
|
6
|
+
"prompt": "I need to add structured request logging to enlace as a middleware that logs every request with timestamp, method, path, status, duration, and the app name extracted from the route prefix. This is part of Phase 4 in the spec. Add it to compose.py's middleware stack.",
|
|
7
|
+
"expected_output": "The agent creates a pure ASGI middleware (NOT BaseHTTPMiddleware), extracts app_id from path prefix, uses time.perf_counter_ns for duration, wraps the send callable to capture status code. Adds it to the middleware stack in build_backend(). Reads the deployment_observability research doc for the structlog pattern.",
|
|
8
|
+
"files": []
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
"id": 2,
|
|
12
|
+
"prompt": "The show-config command currently works but it re-does the full discovery+import every time. I want to add a new enlace init command that creates a platform.toml and apps/ directory in the current directory. Keep it simple.",
|
|
13
|
+
"expected_output": "The agent adds an init() function, creates a default platform.toml and apps/ directory, registers the command in __main__.py's dispatch list, and exports from __init__.py. Follows the argh pattern used by existing commands.",
|
|
14
|
+
"files": []
|
|
15
|
+
}
|
|
16
|
+
]
|
|
17
|
+
}
|