create-caspian-app 1.5.3 → 1.5.5
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.
- package/dist/.github/copilot-instructions.md +4 -1
- package/dist/AGENTS.md +29 -6
- package/dist/main.py +50 -12
- package/dist/tests/test_main_helpers.py +48 -4
- package/package.json +1 -1
|
@@ -24,6 +24,9 @@ This is the top architectural requirement for this workspace. Treat it as a hard
|
|
|
24
24
|
|
|
25
25
|
## Global Rules
|
|
26
26
|
|
|
27
|
+
- **Required frontend feedback loop:** for every UI change or browser-error fix, run `npm run logs` before editing and after reloading the affected route and exercising the changed interactions. It reads `.casp/browser-log.jsonl`; the developer's terminal is not your only source of frontend errors. Read both errors and warnings. A green Python/template/test gate or a visually correct first paint is not browser verification. If the browser or dev session is unavailable, report that verification gap explicitly.
|
|
28
|
+
- **Read `pp.props` in the owning script, never directly in authored markup.** Forward incoming props onto the native root, then expose top-level script bindings: `const dialogOpen = !!pp.props.open; const isRotating = !!pp.props.rotate; const projectId = pp.props.projectId || '';`. Use `open="{dialogOpen}"`, `hidden="{isRotating}"`, and `project-id="{projectId}"`. Nested boundary/slot evaluation does not guarantee the script-local prop bag; `open="{!!pp.props.open}"` can throw even when root forwarding is correct. Script-defined callbacks/effects/RPC handlers may use `pp.props`. Do not mask this with optional chaining, replace browser `{...}` with Jinja `{{...}}`, or freeze derived props in state. Read `pulsepoint.md` "Read props in the script, bind names in markup" and `components.md` before editing prop-driven components.
|
|
29
|
+
|
|
27
30
|
- Use this decision order: `caspian.config.json` for optional feature enablement, app runtime and app-owned code for current project behavior, matching workspace instruction files under `.github/instructions/**/*.instructions.md` for task-specific implementation guidance, installed `casp` runtime for framework internals, and packaged markdown docs for Caspian feature discovery and task routing.
|
|
28
31
|
- As the app grows, prefer `src/components/` for reusable application UI and reserve `src/lib/` for reusable non-UI code such as services, validators, adapters, and shared helpers.
|
|
29
32
|
- Read `./caspian.config.json` almost immediately before making feature, tooling, scaffolding, or file-placement decisions. Treat it as the workspace feature gate for flags such as `backendOnly`, `tailwindcss`, `mcp`, `prisma`, `typescript`, `websocket`, and `componentScanDirs`.
|
|
@@ -45,7 +48,7 @@ This is the top architectural requirement for this workspace. Treat it as a hard
|
|
|
45
48
|
- Use `npm run build` for deployment prep or an explicit build request, not as the default validation step for routine route, feature, or documentation edits.
|
|
46
49
|
- **This workspace has an app-level quality gate, and running it is mandatory — not optional.** Any time you create, edit, or delete app-owned Python (`main.py`, `src/**`) — whether fixing a bug, adding a new file, refactoring, or implementing a feature — you must run `npm run check` (which calls `uv run python settings/check.py`) and get it fully green before treating the change as done. **A change is not complete while the gate reports anything.** It type checks with `pyright`, lints with `ruff`, and runs `pytest` in one pass, prints each problem as `path:line:col [tool:code] message`, and exits non-zero; fix every reported location and re-run until it passes clean. Do not report work as finished, hand it back, or move on to the next task on the assumption that it passes — actually run it and confirm green output first. Write app-owned Python to pass type checking (annotate parameters and returns, avoid untyped `Any` drift) and add or extend tests in `tests/` for the behavior you change. See `### tests/**/*.py and settings/check.py`.
|
|
47
50
|
- **Know the gate's boundary: `npm run check` validates Python only** (`pyright` + `ruff` + `pytest` over `main.py`, `src/**`, and `settings/*.py`). It does **not** validate authored markup (the templates inside `index.py`, `layout.py`, and component `.py` files), `globals.css`/Tailwind, or `public/js/**` — a broken template, an invalid `x-*` tag, a single-root violation, or a PulsePoint error will pass the gate and only surface at render time. So a green gate means "the Python is sound," not "the page works." When you change templates, components, styles, or browser JS, verify them by actually loading the affected route in the browser (use the BrowserSync URL from `./settings/bs-config.json`) and checking it renders without console errors — do not treat a passing `npm run check` as proof that front-end work is correct.
|
|
48
|
-
- **To see browser-side errors, run `npm run logs` — never start a second `npm run dev`.** The dev terminal usually belongs to the developer, so its stdout is invisible to you. `npm run dev` starts by deleting `.casp/` and `caches/`, so launching your own copy corrupts the running server's state, takes different ports, and rewrites `settings/bs-config.json`. Instead, PulsePoint's browser errors are appended to `.casp/browser-log.jsonl` and rendered by `npm run logs` (also printed at the end of every `npm run check`, where it never affects the exit code). Read its verdicts literally: `CLEAN` means that route was opened and rendered without error; a route **absent from the listing was never opened**, which is no signal rather than a pass; and `dev server is NOT running` means the entries are leftover history, not current state. After a fix,
|
|
51
|
+
- **To see browser-side errors, run `npm run logs` — never start a second `npm run dev`.** The dev terminal usually belongs to the developer, so its stdout is invisible to you. `npm run dev` starts by deleting `.casp/` and `caches/`, so launching your own copy corrupts the running server's state, takes different ports, and rewrites `settings/bs-config.json`. Instead, PulsePoint's browser errors are appended to `.casp/browser-log.jsonl` and rendered by `npm run logs` (also printed at the end of every `npm run check`, where it never affects the exit code). Read its verdicts literally: `CLEAN` means that route was opened and rendered without error; a route **absent from the listing was never opened**, which is no signal rather than a pass; and `dev server is NOT running` means the entries are leftover history, not current state. After a fix, reload and exercise the affected route, then re-run `npm run logs`; verify the behavior and absence of fresh errors or relevant warnings. **What counts as exercising it depends on the status:** a mount error is cleared by a reload, but `NEEDS RECHECK` can mean an interaction error or an error carried across a source change. Repeat the affected interaction (click/submit). The reporter does not observe successful clicks, so historical recheck entries can remain; report the retest evidence and remaining history separately instead of claiming the digest is `CLEAN`. **Prefer the digest over raw `.casp/browser-log.jsonl`:** the file includes history and is compacted on source changes. A later matching load can retire earlier mount errors, but interaction and carried entries can still need rechecking. If inspecting raw events to correlate a retest, use session, route, page id, and timestamp; a later event does not unconditionally clear every earlier error. Never delete logs or restart the server just to make the status look clean. `UNCONFIRMED` means an error with no matching load in this log (a tab left open across a dev restart); ask for a reload before treating it as live. Details in `AGENTS.md`.
|
|
49
52
|
- Let the running dev stack own generated outputs such as `public/css/styles.css`, `settings/component-map.json`, `settings/files-list.json`, `__pycache__/`, and `.pyc` files. Treat those as generated artifacts rather than authored source.
|
|
50
53
|
- Never treat `__pycache__/` directories or `.pyc` files as files to edit, regenerate on purpose, or keep in the final diff.
|
|
51
54
|
- Treat `settings/component-map.json` and `settings/files-list.json` as generated outputs owned by `settings/component-map.ts` and `settings/files-list.ts`; inspect them when needed, but do not hand-edit them.
|
package/dist/AGENTS.md
CHANGED
|
@@ -12,6 +12,8 @@ When you work here, use `caspian.config.json` and the code that actually runs as
|
|
|
12
12
|
|
|
13
13
|
Do not treat the existence of a packaged doc as proof that the feature is enabled in this project.
|
|
14
14
|
|
|
15
|
+
**Frontend verification is required for every UI change or browser-error fix.** Run `npm run logs` before editing to read the frontend reports from `.casp/browser-log.jsonl`, then reload the affected route, exercise the changed interactions, and run it again before reporting completion. A passing `npm run check` does not prove browser health: its browser-log section is informational and does not affect the exit code. Never skip this because Python tests passed or the first paint looks correct. See the browser-log rules under Workspace Clarifications for historical entries and unavailable browser access.
|
|
16
|
+
|
|
15
17
|
## Caspian Core Contracts (Read Before Any Analysis)
|
|
16
18
|
|
|
17
19
|
Every rule in this section describes shipped behavior of the Caspian runtime this app runs on. Implement against these contracts, not framework intuition. When any claim here disagrees with `caspian.config.json`, the app code, or the installed runtime, the code wins — and this section should then be fixed together with the matching packaged doc.
|
|
@@ -135,12 +137,33 @@ Value-type contract (forwarding fixes presence, not type):
|
|
|
135
137
|
| `class`, `for` — JS reserved words | dropped; `pp.props.class` never exists |
|
|
136
138
|
| value was `None`/`False`/`""`/empty collection | attribute omitted by `get_attributes` → `undefined` (never `false`) |
|
|
137
139
|
|
|
138
|
-
Design rules: read booleans defensively (
|
|
140
|
+
Design rules: read booleans defensively inside the owning script (`const isPlaying = !!pp.props.playing;`) and use `{isPlaying}` in markup; coerce server literals before strict comparison; avoid native-attribute collisions (`title` makes a tooltip — prefer `user-name`); camelCase round-trips through kebab-case (`isFullscreen` ↔ `is-fullscreen`). `get_attributes` aliases: `className`/`class_name` → `class`, `htmlFor`/`html_for` → `for`, `defaultValue` → `defaultvalue`, `defaultChecked` → `defaultchecked`. When Tailwind is enabled, `merge_classes(...)` emits a live `{twMerge(...)}` expression — pass it straight through, never wrap or re-merge it, and pop the incoming `class` from `props` first.
|
|
139
141
|
|
|
140
142
|
`pp-ref` on an `x-*` tag is parent-owned and binds the component's concrete DOM root (forwarded through composition hosts). A component can opt out by declaring an explicit `ppRef` parameter. For a child-defined imperative API, pass a parent ref as an ordinary prop and publish with `pp.imperativeHandle(pp.props.controlRef, () => ({...}), [])` — never author `pp-ref-forward`.
|
|
141
143
|
|
|
142
144
|
Deep dive: `node_modules/caspian-utils/dist/docs/components.md` "Receiving Props In A Python Component", "Every Prop A Template Reads Must Be Forwarded To The Root", and "HTML Attribute Helper Contract" (the full `get_attributes`/`merge_classes` behavior and end-to-end examples).
|
|
143
145
|
|
|
146
|
+
### Read props in the script; expose bindings to the template
|
|
147
|
+
|
|
148
|
+
Forwarding props to the root and making them available to markup are separate requirements. Read `pp.props` inside the owning component's `<script>`, then expose top-level bindings for `{...}` expressions. Do not access `pp.props` directly in authored markup, including child `x-*` attributes, slot content, text, boolean attributes, or inline event expressions. Nested boundary and owned-slot evaluation cannot be assumed to receive the script-local `pp.props` bag.
|
|
149
|
+
|
|
150
|
+
```html
|
|
151
|
+
<!-- Wrong: open="{!!pp.props.open}", hidden="{!!pp.props.rotate}",
|
|
152
|
+
project-id="{pp.props.projectId}" -->
|
|
153
|
+
<x-dialog open="{dialogOpen}">
|
|
154
|
+
<p hidden="{isRotating}">Enter a replacement value.</p>
|
|
155
|
+
</x-dialog>
|
|
156
|
+
<script>
|
|
157
|
+
const dialogOpen = !!pp.props.open;
|
|
158
|
+
const isRotating = !!pp.props.rotate;
|
|
159
|
+
const projectId = pp.props.projectId || "";
|
|
160
|
+
</script>
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
This is a template excerpt: keep it inside the component's single root, forward its incoming props, and import the child component. Pass `project-id="{projectId}"` at the child call site. Script-defined callbacks, effects, and RPC handlers may still read `pp.props`. These are browser `{...}` bindings, not server Jinja `{{...}}` interpolation. Do not change brace dialects or use `pp.props?.open` to hide a scope error. Derive bindings each render; do not freeze incoming props with `pp.state(pp.props.open)` unless intentionally creating independent state.
|
|
164
|
+
|
|
165
|
+
Diagnostic signatures include `Failed to eval "!!pp.props.open"`, `Failed to evaluate prop expression`, and `Cannot read properties of undefined (reading 'rotate')` from owned content. A missing field in a valid prop bag is different from the bag itself being unavailable. Check root forwarding, then script-to-template scope, then repeat the interaction and inspect the frontend log. See [Read props in the script, bind names in markup](node_modules/caspian-utils/dist/docs/pulsepoint.md#read-props-in-the-script-bind-names-in-markup).
|
|
166
|
+
|
|
144
167
|
### PulsePoint templates — plain HTML, never JSX
|
|
145
168
|
|
|
146
169
|
The React comparison covers exactly two things: the `pp.*` hook API inside `<script>` and how components are split by responsibility. **The markup is plain HTML parsed by an HTML parser.** The one-line test before finishing any template: _would it still be valid HTML with every `{}` deleted?_
|
|
@@ -174,7 +197,7 @@ Deep dive: `node_modules/caspian-utils/dist/docs/pulsepoint.md` "PulsePoint Is N
|
|
|
174
197
|
|
|
175
198
|
### Component scripts — hooks and runtime API
|
|
176
199
|
|
|
177
|
-
The script is a plain, untyped `<script>` inside the root: captured by the runtime before materialization, evaluated in component scope via `new Function(...)`. No `import`/`export`/top-level `await`. Only **top-level** declarations reach the template (functions, `const`s, every destructuring shape).
|
|
200
|
+
The script is a plain, untyped `<script>` inside the root: captured by the runtime before materialization, evaluated in component scope via `new Function(...)`. No `import`/`export`/top-level `await`. Only **top-level** declarations reach the template (functions, `const`s, every destructuring shape). Inside the script, props are read via `pp.props` — there is no injected `props` variable. Export top-level bindings for markup; do not read `pp.props` directly in template expressions.
|
|
178
201
|
|
|
179
202
|
Hooks (closed list): `pp.state`, `pp.effect`, `pp.layoutEffect`, `pp.ref`, `pp.memo`, `pp.callback`, `pp.reducer`, `pp.context`, `pp.portal`, `pp.id`, `pp.errorBoundary`, `pp.syncExternalStore`, `pp.imperativeHandle`, `pp.transition`, `pp.deferredValue`, `pp.optimistic`, plus `pp.props`. Utilities: `pp.createContext`, `pp.mount`, `pp.redirect`, `pp.rpc`, `pp.socket`, `pp.enablePerf`/`disablePerf`/`getPerfStats`/`resetPerfStats`. No `forwardRef`, `Suspense`, `lazy`, `useActionState`, or `pp.provideContext` — do not invent hooks.
|
|
180
203
|
|
|
@@ -289,7 +312,7 @@ Use `.github/copilot-instructions.md` for the repo-wide implementation rules. Th
|
|
|
289
312
|
- Use `node_modules/caspian-utils/dist/docs/file-conventions.md` for the general special-file model, then verify the completed Python migration in `main.py` and `.venv/Lib/site-packages/casp/**`: routes use `index.py`, layouts use `layout.py`, navigation loading UI uses `loading.py`, and global fallback pages use `not_found.py` and `error.py`. This app has no authored `.html` special files.
|
|
290
313
|
- **If navigation loading UI is wanted, it is `loading.py` — never hand-roll it.** The file itself is optional and most subtrees do not have one; this rule governs the implementation, not whether to add the feature. A spinner component, a global `isLoading` store, a `pp:navigation:start`/`pp:navigation:complete` listener, or a manual overlay built for route-to-route navigation is a reimplementation of the shipped runtime (`casp/loading.py` collects the files, `caspian_config.py` derives their URL scopes, the browser runtime resolves the closest ancestor scope and swaps the `pp-loading-content="true"` pane). The full contract is in the "Special files" block above; the shipped example is `src/app/dashboard/loading.py` plus the `pp-loading-content="true"` bar in `src/app/dashboard/layout.py`. In-page waits (RPC, submit, filter, upload) are `pp.state` in the owning component and are not this feature.
|
|
291
314
|
- When `caspian.config.json` has `prisma: true`, database reads and writes from Python routes, layouts, RPC actions, upload flows, auth flows, and helpers must use the generated Prisma Python ORM in `src/lib/prisma/**`. Do not create a separate database fetch layer with raw drivers, hand-written SQL helpers, JSON manifests, app-specific HTTP fetches, or browser-side data fetches to replace the ORM. Use raw SQL only as a narrow Prisma ORM fallback when the generated client cannot express a query clearly.
|
|
292
|
-
- Development restarts must preserve the database lifecycle. `settings/python-server.ts` sends an authenticated shutdown command through a private child stdin pipe and waits for Uvicorn to exit before force-kill is considered; `main.py` handles that command with `uvicorn.Server.should_exit`, and, only when `cfg.prisma` is true, conditionally imports the generated client and registers a Prisma lifespan that awaits `prisma.disconnect()` during shutdown. A Prisma-disabled project
|
|
315
|
+
- Development restarts must preserve the database lifecycle. `settings/python-server.ts` sends an authenticated shutdown command through a private child stdin pipe and waits for Uvicorn to exit before force-kill is considered; `main.py` handles that command with `uvicorn.Server.should_exit`, and, only when `cfg.prisma` is true, conditionally imports the generated client and registers a Prisma lifespan that awaits `prisma.disconnect()` during shutdown. **Two independent conditions gate that import, not one.** `cfg.prisma` says the feature is _enabled_; `src/lib/prisma/` existing on disk says the ORM has actually been _generated_. A Prisma-disabled project has no such package, and neither does a freshly scaffolded or freshly cloned project whose `prisma/schema.prisma` exists but where `npx ppy generate` has never run — so the import is guarded by `PRISMA_PACKAGE_DIR.is_dir()` inside the `cfg.prisma` gate, and the lifespan is registered on `prisma is not None` rather than on the flag. Never move the import outside either guard, and do not collapse them back into one: a bare `ModuleNotFoundError` at import kills the dev stack before a single route renders, with a traceback that names no fix. A directory that exists but fails to import is a genuinely broken install (missing driver dependency, half-written generation) and keeps its own traceback. Missing-and-enabled warns and keeps serving in development but raises at boot in production, where every database call would fail at request time anyway — the same fail-closed rule as `APP_ENV`. Every consumer must therefore treat `main.prisma` as possibly `None`. Keep Prisma's lifespan ahead of later lifespans so it exits last, do not make it connect eagerly, and do not replace the normal restart path with immediate `taskkill /F` / `SIGKILL`—that bypasses FastAPI cleanup and churns database connections during source edits.
|
|
293
316
|
- **After any `prisma/schema.prisma` change, exactly two commands are required, in order.** Step 1 — sync the database, pick one: `npx prisma migrate dev` (development default, creates and applies a migration) or `npx prisma db push` (migration-less direct sync). Step 2 — always: `npx ppy generate`, the **only** command that regenerates the Python ORM the app imports (`src/lib/prisma/__init__.py`, `db.py`, `models.py`, `settings/prisma-schema.json`). The two generators are different toolchains from the same schema: `npx prisma generate` builds the Node/TypeScript `@prisma/client` used only by `prisma/seed.ts` and writes zero Python — it is never a substitute for `npx ppy generate`. Never hand-write or patch the generated Python ORM instead of regenerating it; the generated client is ready to import from `src.lib.prisma`. See `node_modules/caspian-utils/dist/docs/database.md` "Two Generators, One Schema".
|
|
294
317
|
- Treat `npx prisma db seed` as a delicate, potentially destructive operation. In this workspace, seed scripts may clear tables before inserting fresh records. Before running that command, an AI agent must propose the exact command, warn that it can delete or overwrite database data including production data if the datasource is wrong, confirm the datasource when practical, and wait for explicit user approval.
|
|
295
318
|
- Component-first page composition is the highest-priority authoring rule for this workspace (see `.github/copilot-instructions.md`). Build pages as a short assembly of `x-*` chunk components (top menu, sidebar, header, content sections, cards, forms, footer) and keep each chunk's long markup inside its own focused single-file `html(...)` component, so the page template in `src/app/**/index.py` stays small instead of holding a wall of HTML. Plan the chunk breakdown before writing the route, not as a later cleanup pass.
|
|
@@ -331,12 +354,12 @@ Use `.github/copilot-instructions.md` for the repo-wide implementation rules. Th
|
|
|
331
354
|
- **A BOM makes a Python file invisible to the `templates` gate.** `check_templates.py` reads files with `path.read_text(encoding="utf-8")` and `ast.parse`, and a UTF-8 BOM makes that parse raise, which the scanner swallows as "skip this file". One file (`src/components/dashboard/ProductsPage.py`) carried a BOM and was silently exempt from every template rule, including `html-form` — it had been using the non-raw `html("""...""")` shape the whole time. `ruff format` strips the BOM, so the violation surfaced the moment formatting ran. If a template rule ever seems not to apply to a file, check for a BOM before assuming the rule is wrong.
|
|
332
355
|
- **`npm run logs` is how an agent checks front-end health, because the dev terminal usually belongs to someone else.** The developer typically runs `npm run dev` in their own shell, so its stdout is invisible to an agent session — and starting a second dev stack is the wrong fix: `npm run dev` begins with `projectName`, which **deletes `.casp/` and `caches/`** out from under the running server, and then binds different ports and rewrites `settings/bs-config.json`, orphaning the browser tab the developer is actually looking at. **Never start a second `npm run dev` to get a log.** Instead `settings/dev-log-bridge.ts` appends every event to `.casp/browser-log.jsonl` (JSONL, one event per line, gitignored, truncated per dev session because `.casp/` is recreated at startup), and `settings/browser_log.py` renders it via `npm run logs`. `npm run check` prints the same digest at the end of its run but **never lets it affect the exit code** — whether a route has been exercised depends on someone clicking around, and a gate that flaky gets ignored; `--fail-on-error` opts in for scripts that want it, `--no-browser` skips the section.
|
|
333
356
|
- **The log records successful page loads, not just errors.** This is the property that makes it safe to act on, and it must not be removed as redundant. A clean reload writes nothing on its own, so without `load` events a fixed error would sit in the file forever and an agent would "fix" a bug that no longer exists. A route's status is therefore whatever happened during its **most recent load**: one clean reload retires every earlier error for that route (reported as `N earlier error(s) resolved`). Errors are tied to their load by a client-generated `page` id, never by arrival order, because two `fetch` POSTs can land out of sequence.
|
|
334
|
-
- **`NEEDS RECHECK`
|
|
357
|
+
- **`NEEDS RECHECK` can represent interaction errors or errors carried across source changes — a reload does not re-test everything.** A reload re-runs mount, so it is real evidence against a mount-phase error. It never clicks a button, so it proves nothing about an error thrown from an event handler. Errors are classified by how long after their page load they arrived (`phase: "mount"` within 2s, `"interaction"` after), and an interaction error is **not** cleared by a later load — it is carried as `NEEDS RECHECK` with its timing shown. Treating that as `CLEAN` is exactly how a live bug gets signed off; this was a real defect in an earlier version of this tool, on a route whose `onclick` threw 17s after load. Repeat the interaction (click/submit) and re-run `npm run logs`. The reporter does not record successful clicks, so an old recheck entry may remain even after a successful retest. Confirm there are no fresh errors or relevant warnings and report the interaction evidence separately; do not claim the status became `CLEAN` if it did not.
|
|
335
358
|
- **The log is compacted on every source change, not appended forever.** A dev session left running for hours would otherwise grow an unbounded file (~331 KB / ~85k tokens in a measured 6-hour session), and `.casp/` is only wiped by a full `npm run dev`, which nobody does mid-session. So when `src/**` changes, `compactBrowserLog(...)` rewrites the file down to the session header, a `{"type":"restart"}` marker, and the errors still open — resolved history is dropped. Survivors are marked `"carried": true` and dropped at the _next_ compaction, so a stale interaction error cannot haunt the log forever. Reading the digest costs ~855 tokens regardless of session length, because it is bounded by route count, not time.
|
|
336
|
-
- **Do not diagnose from
|
|
359
|
+
- **Do not diagnose from raw JSONL alone.** It is history, not state: it can hold errors resolved minutes ago, and errors carried across a source change. Always prefer `npm run logs`, which derives current status. If you do read the file, the `session` line's `readme` states the rule — **a later matching load can retire earlier mount errors, but interaction errors and carried entries can remain pending recheck.** When the digest retains history after a retest, correlate raw details by session, route, page id, and timestamp; never treat every line as a new failure.
|
|
337
360
|
- **`UNCONFIRMED` means an error with no matching `load` in this log** — almost always a browser tab left open across a dev restart, reporting against a page that was rendered under the previous session. The error was real when it fired but may already be fixed. Ask for a reload of that route and re-run `npm run logs` before treating it as a live bug; do not start editing code on an `UNCONFIRMED` line alone.
|
|
338
361
|
- **Read the three not-an-error states literally.** `NONE` = no dev session ever wrote — nothing has been observed. `WARN … dev server is NOT running` = the log is left over from an exited session; treat every line as history. `CLEAN` on a route means _that route was opened and rendered without error_. A route absent from the listing was never opened, which is **no signal, not a pass** — say so rather than reporting the front end healthy.
|
|
339
|
-
- **How to act on a failure.** The digest gives route, message, and the top stack frames. Fix the owning `src/app/<route>/index.py` or the component it names, then get the route exercised again and re-run `npm run logs`. What "exercised" means depends on the status: a mount error needs a **reload**; a `NEEDS RECHECK` interaction error needs the **same interaction repeated** — a reload will not clear it and should not be expected to.
|
|
362
|
+
- **How to act on a failure.** The digest gives route, message, and the top stack frames. Fix the owning `src/app/<route>/index.py` or the component it names, then get the route exercised again and re-run `npm run logs`. What "exercised" means depends on the status: a mount error needs a **reload**; a `NEEDS RECHECK` interaction error needs the **same interaction repeated** — a reload will not clear it and should not be expected to. A `CLEAN` digest supports the retest, but historical `NEEDS RECHECK` entries may remain because the reporter cannot observe successful interactions. Record what was exercised and whether any fresh errors or warnings followed. Do not treat a retained historical entry as a fresh failure or claim browser verification when the route or interaction was never exercised. If browser access or the dev session is unavailable, state that gap explicitly.
|
|
340
363
|
- Coverage is in `tests/test_browser_log.py`. The event shape is defined by `LogEvent` in `settings/dev-log-bridge.ts` and consumed by `build_report(...)` in `settings/browser_log.py`; change both together. Known limit: the client script runs per full document load, so **SPA navigations do not emit a `load` event** — the log tracks full page loads only.
|
|
341
364
|
|
|
342
365
|
- Security posture is enforced in three places and must stay in sync: `casp.runtime_security` (environment resolution, security headers, safe public-file serving), `main.py` (middleware stack, MCP gate, cache eligibility, the named-socket endpoint), and the Caspian-owned files under `src/lib/**` — `src/lib/auth/auth_config.py` (route privacy and RBAC policy) and `src/lib/websocket/sockets.py` (socket origin check, connection ceiling, auth delegation, and per-connection limits). Key behaviors an agent must not silently undo:
|
package/dist/main.py
CHANGED
|
@@ -81,19 +81,55 @@ from collections.abc import Callable
|
|
|
81
81
|
load_dotenv()
|
|
82
82
|
cfg = get_config()
|
|
83
83
|
|
|
84
|
-
# Prisma
|
|
85
|
-
#
|
|
86
|
-
#
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
from src.lib.prisma import prisma as configured_prisma
|
|
84
|
+
# Declared before the Prisma and MCP blocks below, which need it to decide
|
|
85
|
+
# whether a missing generated client, an unauthenticated endpoint, or an open
|
|
86
|
+
# CORS policy is tolerable. Resolved fail-closed: only an explicit development
|
|
87
|
+
# APP_ENV turns the relaxations on.
|
|
88
|
+
IS_PRODUCTION = is_production_environment()
|
|
90
89
|
|
|
91
|
-
|
|
90
|
+
# Prisma is optional, and its Python ORM is *generated* rather than authored, so
|
|
91
|
+
# there are two independent conditions -- the feature flag in
|
|
92
|
+
# `caspian.config.json`, and whether `npx ppy generate` has actually produced
|
|
93
|
+
# `src/lib/prisma/`. A project generated with `prisma: false` has no such
|
|
94
|
+
# package; neither does a freshly cloned or freshly scaffolded project whose
|
|
95
|
+
# schema exists but has never been generated. Both must leave the app importable,
|
|
96
|
+
# so the import, the lifespan registration, and every consumer treat `prisma` as
|
|
97
|
+
# possibly `None`.
|
|
98
|
+
PRISMA_PACKAGE_DIR = Path(__file__).resolve().parent / "src" / "lib" / "prisma"
|
|
99
|
+
|
|
100
|
+
PRISMA_NOT_GENERATED_MESSAGE = (
|
|
101
|
+
"Prisma is enabled in caspian.config.json but the Python ORM has not been "
|
|
102
|
+
"generated, so src/lib/prisma/ does not exist. Sync the database with "
|
|
103
|
+
"`npx prisma migrate dev` (or `npx prisma db push`), then run "
|
|
104
|
+
"`npx ppy generate` to generate the client."
|
|
105
|
+
)
|
|
92
106
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
107
|
+
prisma: Any = None
|
|
108
|
+
if cfg.prisma:
|
|
109
|
+
if PRISMA_PACKAGE_DIR.is_dir():
|
|
110
|
+
# Present but unimportable is a genuinely broken install (a missing
|
|
111
|
+
# driver dependency, a half-written generation). Let that traceback
|
|
112
|
+
# through rather than degrading to a confusing "not generated" hint.
|
|
113
|
+
# The ignore matches the optional MCP import below: a generated module
|
|
114
|
+
# is absent from a static checkout, so the type checker cannot see it.
|
|
115
|
+
from src.lib.prisma import prisma as configured_prisma # type: ignore[import-not-found]
|
|
116
|
+
|
|
117
|
+
prisma = configured_prisma
|
|
118
|
+
elif IS_PRODUCTION:
|
|
119
|
+
# A deployment that enabled Prisma and shipped without the generated
|
|
120
|
+
# client is broken: every database call would fail at request time.
|
|
121
|
+
# Fail at boot instead, consistent with the fail-closed APP_ENV rule.
|
|
122
|
+
raise RuntimeError(PRISMA_NOT_GENERATED_MESSAGE)
|
|
123
|
+
else:
|
|
124
|
+
# In development this is the ordinary "I have not generated it yet"
|
|
125
|
+
# state. Warn once at boot and keep serving, so the dev stack does not
|
|
126
|
+
# die with a bare ModuleNotFoundError before a single route renders.
|
|
127
|
+
print(
|
|
128
|
+
f"[caspian] WARNING: {PRISMA_NOT_GENERATED_MESSAGE} "
|
|
129
|
+
"Database access is unavailable until then; the rest of the app runs normally.",
|
|
130
|
+
file=sys.stderr,
|
|
131
|
+
flush=True,
|
|
132
|
+
)
|
|
97
133
|
|
|
98
134
|
# Resolve APP_TIMEZONE once at import so an unknown zone name fails at boot with
|
|
99
135
|
# a named error, rather than on whichever request first formats a date. Only the
|
|
@@ -324,7 +360,9 @@ def get_app_lifespans() -> list[LifespanFactory]:
|
|
|
324
360
|
|
|
325
361
|
# Keep the database alive until every later lifespan has shut down. This does
|
|
326
362
|
# not connect eagerly; it only guarantees cleanup when Uvicorn exits cleanly.
|
|
327
|
-
|
|
363
|
+
# Gated on the client being importable, not just on the feature flag: with
|
|
364
|
+
# Prisma enabled but not yet generated there is nothing to disconnect.
|
|
365
|
+
if prisma is not None:
|
|
328
366
|
lifespans.append(prisma_lifespan)
|
|
329
367
|
|
|
330
368
|
# MCP lifecycle
|
|
@@ -16,9 +16,21 @@ from conftest import run_async
|
|
|
16
16
|
|
|
17
17
|
|
|
18
18
|
class TestPrismaLifespan:
|
|
19
|
-
|
|
19
|
+
@staticmethod
|
|
20
|
+
def _install_client(monkeypatch) -> AsyncMock:
|
|
21
|
+
"""Stand in for the generated Prisma client.
|
|
22
|
+
|
|
23
|
+
`src/lib/prisma/` is generated by `npx ppy generate`, so it is absent in
|
|
24
|
+
a fresh checkout and `main.prisma` is then `None`. The lifespan contract
|
|
25
|
+
is about whatever object `main.prisma` holds, so the tests supply one
|
|
26
|
+
rather than depending on generation having been run.
|
|
27
|
+
"""
|
|
20
28
|
disconnect = AsyncMock()
|
|
21
|
-
monkeypatch.setattr(main
|
|
29
|
+
monkeypatch.setattr(main, "prisma", SimpleNamespace(disconnect=disconnect))
|
|
30
|
+
return disconnect
|
|
31
|
+
|
|
32
|
+
def test_disconnects_on_shutdown(self, monkeypatch):
|
|
33
|
+
disconnect = self._install_client(monkeypatch)
|
|
22
34
|
|
|
23
35
|
async def exercise_lifespan():
|
|
24
36
|
async with main.prisma_lifespan(main.app):
|
|
@@ -29,12 +41,29 @@ class TestPrismaLifespan:
|
|
|
29
41
|
|
|
30
42
|
def test_disabled_prisma_is_not_registered(self, monkeypatch):
|
|
31
43
|
monkeypatch.setattr(main, "cfg", replace(main.cfg, prisma=False))
|
|
44
|
+
monkeypatch.setattr(main, "prisma", None)
|
|
32
45
|
|
|
33
46
|
assert main.prisma_lifespan not in main.get_app_lifespans()
|
|
34
47
|
|
|
48
|
+
def test_enabled_but_ungenerated_prisma_is_not_registered(self, monkeypatch):
|
|
49
|
+
"""Prisma enabled in config but never generated leaves nothing to close.
|
|
50
|
+
|
|
51
|
+
Registration is gated on the client being importable, not on the flag,
|
|
52
|
+
so the app still boots when `npx ppy generate` has not been run.
|
|
53
|
+
"""
|
|
54
|
+
monkeypatch.setattr(main, "cfg", replace(main.cfg, prisma=True))
|
|
55
|
+
monkeypatch.setattr(main, "prisma", None)
|
|
56
|
+
|
|
57
|
+
assert main.prisma_lifespan not in main.get_app_lifespans()
|
|
58
|
+
|
|
59
|
+
def test_generated_prisma_is_registered(self, monkeypatch):
|
|
60
|
+
monkeypatch.setattr(main, "cfg", replace(main.cfg, prisma=True))
|
|
61
|
+
self._install_client(monkeypatch)
|
|
62
|
+
|
|
63
|
+
assert main.prisma_lifespan in main.get_app_lifespans()
|
|
64
|
+
|
|
35
65
|
def test_disconnects_when_another_lifespan_raises(self, monkeypatch):
|
|
36
|
-
disconnect =
|
|
37
|
-
monkeypatch.setattr(main.prisma, "disconnect", disconnect)
|
|
66
|
+
disconnect = self._install_client(monkeypatch)
|
|
38
67
|
|
|
39
68
|
async def exercise_lifespan():
|
|
40
69
|
try:
|
|
@@ -46,6 +75,21 @@ class TestPrismaLifespan:
|
|
|
46
75
|
run_async(exercise_lifespan())
|
|
47
76
|
disconnect.assert_awaited_once_with()
|
|
48
77
|
|
|
78
|
+
def test_lifespan_is_a_noop_without_a_generated_client(self, monkeypatch):
|
|
79
|
+
"""The shutdown half must tolerate `prisma` being `None`.
|
|
80
|
+
|
|
81
|
+
`get_app_lifespans` already skips registration in that state, but the
|
|
82
|
+
lifespan is public and is exercised directly by tests and by anyone
|
|
83
|
+
composing lifespans by hand.
|
|
84
|
+
"""
|
|
85
|
+
monkeypatch.setattr(main, "prisma", None)
|
|
86
|
+
|
|
87
|
+
async def exercise_lifespan():
|
|
88
|
+
async with main.prisma_lifespan(main.app):
|
|
89
|
+
pass
|
|
90
|
+
|
|
91
|
+
run_async(exercise_lifespan())
|
|
92
|
+
|
|
49
93
|
|
|
50
94
|
class TestDevControlPipe:
|
|
51
95
|
def test_valid_shutdown_command_stops_server(self):
|