@happyvertical/smrt-app-shell 0.0.0-stage → 0.60.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/AGENTS.md +99 -0
  2. package/CLAUDE.md +1 -0
  3. package/LICENSE +7 -0
  4. package/README.md +173 -2
  5. package/agents/engine.md +85 -0
  6. package/dist/engine/app-runtime.d.ts +138 -0
  7. package/dist/engine/app-runtime.js +545 -0
  8. package/dist/engine/app-worker.d.ts +1 -0
  9. package/dist/engine/app-worker.js +62 -0
  10. package/dist/engine/commands.d.ts +94 -0
  11. package/dist/engine/commands.js +30 -0
  12. package/dist/engine/definition.d.ts +192 -0
  13. package/dist/engine/definition.js +497 -0
  14. package/dist/engine/errors.d.ts +23 -0
  15. package/dist/engine/errors.js +35 -0
  16. package/dist/engine/host.d.ts +153 -0
  17. package/dist/engine/host.js +791 -0
  18. package/dist/engine/index.d.ts +6 -0
  19. package/dist/engine/index.js +7 -0
  20. package/dist/engine/sandbox.d.ts +60 -0
  21. package/dist/engine/sandbox.js +260 -0
  22. package/dist/engine/snapshots.d.ts +7 -0
  23. package/dist/engine/snapshots.js +54 -0
  24. package/dist/engine/store.d.ts +39 -0
  25. package/dist/engine/store.js +32 -0
  26. package/dist/engine/worker-handle.d.ts +29 -0
  27. package/dist/engine/worker-handle.js +89 -0
  28. package/dist/engine/worker-protocol.d.ts +22 -0
  29. package/dist/engine/worker-protocol.js +20 -0
  30. package/dist/lib/objects/App.d.ts +60 -0
  31. package/dist/lib/objects/App.js +273 -0
  32. package/dist/lib/objects/AppVersion.d.ts +22 -0
  33. package/dist/lib/objects/AppVersion.js +62 -0
  34. package/dist/lib/objects/Approval.d.ts +39 -0
  35. package/dist/lib/objects/Approval.js +101 -0
  36. package/dist/lib/objects/AssistantMessage.d.ts +24 -0
  37. package/dist/lib/objects/AssistantMessage.js +63 -0
  38. package/dist/lib/objects/AssistantThread.d.ts +16 -0
  39. package/dist/lib/objects/AssistantThread.js +44 -0
  40. package/dist/lib/objects/index.d.ts +5 -0
  41. package/dist/lib/objects/index.js +5 -0
  42. package/dist/lib/server/approvals.d.ts +69 -0
  43. package/dist/lib/server/approvals.js +239 -0
  44. package/dist/lib/server/builder.d.ts +16 -0
  45. package/dist/lib/server/builder.js +71 -0
  46. package/dist/lib/server/events.d.ts +16 -0
  47. package/dist/lib/server/events.js +14 -0
  48. package/dist/lib/server/host.d.ts +22 -0
  49. package/dist/lib/server/host.js +96 -0
  50. package/dist/lib/server/owner.d.ts +33 -0
  51. package/dist/lib/server/owner.js +92 -0
  52. package/dist/lib/server/version-store.d.ts +20 -0
  53. package/dist/lib/server/version-store.js +115 -0
  54. package/dist/manifest.json +1680 -0
  55. package/dist/smrt-knowledge.okf.md +95 -0
  56. package/package.json +80 -3
package/AGENTS.md ADDED
@@ -0,0 +1,99 @@
1
+ # @happyvertical/smrt-app-shell
2
+
3
+ An ordinary smrt app (SvelteKit + adapter-node, app-runtime **local** profile)
4
+ that builds and runs a person's own smrt app from a versioned JSON definition,
5
+ for one person: the owner. Epic #4867; this package is #4872. The engine is
6
+ documented in [agents/engine.md](agents/engine.md) (it must not import
7
+ SvelteKit or `src/lib`). README has the route and JSON contracts.
8
+
9
+ ## Where things live
10
+
11
+ | Path | What |
12
+ |---|---|
13
+ | `src/engine/` | `AppHost`, worker thread, migrations, QuickJS sandbox. Finished and tested on its own. |
14
+ | `src/lib/objects/` | Shell models (`objectsDir` in `vite.config.ts`): `App`, `AppVersion`, `Approval`, `AssistantThread`, `AssistantMessage`. |
15
+ | `src/lib/server/smrt.ts` | `createSmrtSvelteKitRuntime`, `prepareDatabase`, `configureShell`. |
16
+ | `src/lib/server/schema.ts` | `prepareShellSchema(db)`: additive first-run schema. |
17
+ | `src/lib/server/host.ts` | Holders: shell db, data root, the one lazily opened `AppHost`. |
18
+ | `src/lib/server/version-store.ts` | `ShellVersionStore` (engine `VersionStore` on `AppVersion` + the `App` row). |
19
+ | `src/lib/server/owner.ts` | Owner check, `Principal`, `assertOwner`, `runAs`. |
20
+ | `src/lib/server/approvals.ts` | Approval service; the only place `approved: true` is set. |
21
+ | `src/lib/server/builder.ts` | `runCommand`/`runAppTool`/`runRest`/`requestRecordDelete`: execute or turn into an Approval. |
22
+ | `src/lib/server/catalog.ts`, `policy.ts`, `mcp-server.ts` | Merged tool catalog, the one policy, the `/mcp` server. |
23
+ | `src/lib/server/http.ts`, `events.ts` | Route plumbing; in-process event bus for SSE. |
24
+ | `src/lib/server/assistant/`, `src/lib/assistant/` | Assistant: turn loop, hold, config, routes (`mountAssistantRoutes`), thread store; dock UI, pure reducer/stream parser, `guidance/system.md`. |
25
+ | `src/routes/` | `/api/shell/*`, `/api/app/[...path]`, `/api/events`, `/mcp`, `/setup`, health. UI pages are placeholders. |
26
+
27
+ ## Invariants
28
+
29
+ - **Owner only.** Owner = ACTIVE membership holding the system `owner` role,
30
+ looked up in shell.db on every check (`isOwner`); nothing in a token, header
31
+ or body is trusted. Every route calls `ownerOf(event)` (401/403, same-origin
32
+ for writes). Every `App.*` builder method and `Approval.decide/pending`
33
+ starts with `assertOwner()`, which reads the async scope's tenant context
34
+ (the session handler sets it; routes and `dispatchTool` use `runAs`).
35
+ `operationPermissions` only aliases the generated routes onto CRUD grants, a
36
+ member role may hold those, so `assertOwner` is the rule.
37
+ - **Approval gate.** A risky builder command, `<model>_delete` tool, REST
38
+ `DELETE` or record delete never runs: it becomes an `Approval`
39
+ (`pending_approval`, idempotent per identical pending action). Only
40
+ `decideApproval` (called by `Approval.decide`) passes the engine's
41
+ `approved: true`; `invariants.test.ts` fails if any other source does, or if
42
+ a route reads an `approved` input. The engine refuses without it as well.
43
+ `undo` is risky when the version being undone has a type conversion (it would
44
+ overwrite newer values with the archived originals); `POST /api/shell/undo`
45
+ then answers 202 `pending_approval` and History links to /approvals. The
46
+ assistant pauses only on the out-of-band `CatalogResponse.pending` marker set
47
+ by `dispatchTool` (from `requestApproval`), never on result text, and shows the
48
+ stored Approval's summary and preview. `/api/app/sync/apply` and other
49
+ reserved core endpoints are refused (only the app's own collections are
50
+ served). Snapshots are retained (20 `method-*`; clean up prunes old
51
+ `migrate-*`/`schema-*`).
52
+ - **Decorator configs are literals** (`@smrt({...})` on `App`, `Approval`). The
53
+ scanner reads them statically; a shared constant silently falls back to full
54
+ CRUD routes. Collections are undecorated. The shell models expose only their
55
+ custom operations (no generated list/get/create/update/delete); keep
56
+ `mcp.test.ts` asserting that.
57
+ - **One policy.** `toolsFor(principal)` / `authorizeCall(principal, name)` /
58
+ `callTool(...)` in `policy.ts` are the only entry for MCP tools (the `/mcp`
59
+ route and the assistant). Non-owner: empty list, 403. App tools never shadow
60
+ shell tools (`app_*`, `approval_*`; `defineModel` refuses those names).
61
+ - **Data layout.** `shell.db` is app-runtime's database file; the user app is
62
+ `<data root>/user-app/` (`app.db`, `snapshots/`, archives inside app.db):
63
+ plain files and directories inside the app-runtime data root, no symlinks, so
64
+ the encrypted backup seals them. `PORT` and `SMRT_DATA_DIR` are honored (the
65
+ desktop sidecar contract); health is `/api/_runtime/health`.
66
+ - **App-subclass rule** lives in the engine (`enableRecipe` creates app
67
+ subclasses of shipped models). The shell never registers user models in its
68
+ own registry.
69
+ - **`prepareDatabase` consumer.** app-runtime's first-run schema hook is
70
+ smrt#4835 (pending, another session). Until it lands the shell passes
71
+ `prepareShellSchema` as the existing `prepareDatabase` option. Do not edit
72
+ `packages/app-runtime` from here. Switching is one line in `smrt.ts`.
73
+ `prepareShellSchema` only creates missing tables (no ALTER): a model that
74
+ gains a column later needs a real migration step added there.
75
+ - **Generated change feed** (`_changes`, `_events`) is limited to the owner by
76
+ `change-feed.ts`.
77
+ - `/mcp` is stateless Streamable HTTP, so `tools/list_changed` cannot be
78
+ pushed on it; the UI and assistant listen to `/api/events` (`changed`).
79
+ `tools/list` is sent with `ttlMs: 0`.
80
+ - Never log request bodies, tokens or definitions; `errorResponse` returns
81
+ messages only, 500s are generic.
82
+
83
+ - **Assistant.** Same policy as `/mcp` (`toolsFor`/`callTool`). No tool
84
+ decides approvals (`approval_decide` is not in the catalog, and
85
+ `ASSISTANT_DENIED_TOOLS` repeats it): only the signed-in owner does, in the UI. A risky call is held (`holdForUser`) on its Approval; only the
86
+ owner's `Approval.decide` applies it. The loop is bounded (`DEFAULT_LIMITS`),
87
+ serial, aborts on disconnect. API key only: `config.ts` refuses
88
+ `claude-cli`/`chatgpt-plan`; never spawn a CLI. Model errors are never
89
+ forwarded or logged beyond class/code (SDK messages can echo keys). Tests use
90
+ a stub through `modelFactory`.
91
+
92
+ ## Tests and build
93
+
94
+ `vitest run` (in-process, SQLite, temp dirs; `test-support.ts` seeds an owner
95
+ and a member; it opens the real driver because the vitest setup would
96
+ pre-create tables). `pnpm build` (tsc engine, `svelte-kit sync`, `vite build`;
97
+ the smrt() plugin generates `src/routes/api/{apps,approvals,_changes,_events,
98
+ _resources}`: git-ignored) and `pnpm typecheck` must pass. The `smrt()` plugin
99
+ needs the models in `src/lib/objects`, set via `objectsDir`.
package/CLAUDE.md ADDED
@@ -0,0 +1 @@
1
+ @AGENTS.md
package/LICENSE ADDED
@@ -0,0 +1,7 @@
1
+ Copyright <2025> <Happy Vertical Corporation>
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
4
+
5
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
6
+
7
+ THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,174 @@
1
- # Temporary Holding Version
1
+ # @happyvertical/smrt-app-shell
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A smrt app that builds and runs a person's own smrt app from definitions. One
4
+ person (the owner) signs in, changes the app (models, fields, code methods,
5
+ recipes) through builder operations or the assistant, risky changes wait for the
6
+ owner's approval, and every change is versioned and can be undone.
7
+
8
+ ```
9
+ smrt-app-shell (SvelteKit + adapter-node, app-runtime local profile)
10
+ ├── shell.db (the app-runtime database) users, sessions, App, AppVersion, Approval
11
+ ├── src/engine AppHost: worker thread, migrations, sandbox
12
+ └── <data root>/user-app/ app.db, snapshots (plain files; the newest 20 `method-*` are kept, clean up prunes old migration ones)
13
+ ```
14
+
15
+ ## Run it
16
+
17
+ Build once, then set up the data folder and the owner link, then start the
18
+ server. Use the same `PORT` for setup and start, because the one-time link
19
+ carries it. Keep the data folder outside the checkout.
20
+
21
+ ```sh
22
+ pnpm --filter @happyvertical/smrt-app-shell build
23
+ cd packages/app-shell
24
+ export SMRT_DATA_DIR=<a new folder outside the checkout> PORT=4796 HOST=127.0.0.1 ORIGIN=http://127.0.0.1:4796
25
+
26
+ node ../cli/bin/smrt.js app setup # or `smrt app setup`: creates the data folder and issues the one-time owner link
27
+ node build # start the server with the same exports
28
+ smrt app open # opens the one-time /setup?token=... link; create the owner there
29
+ ```
30
+
31
+ `node build` alone never prints a setup link: the shell passes no
32
+ `onBootstrapInvitation`, so the link comes from `smrt app setup` (it is saved in
33
+ the app's state folder, not the data folder). If the link is lost, stop the
34
+ server and run `smrt app recover` for a new one. Setup is loopback only and
35
+ single use. Health: `GET /api/_runtime/health`.
36
+
37
+ ## Builder operations (MCP tools and `POST /api/apps/<operation>`)
38
+
39
+ The same names and parameters on both surfaces. A wrong-shaped argument is a
40
+ 400 that says what to send (never a 500); a risky operation answers 202
41
+ `pending_approval` (REST) or returns it as the tool result (MCP).
42
+
43
+ | Operation (tool) | Parameters |
44
+ |---|---|
45
+ | `defineModel` (`app_definemodel`) | `name`, `fields?` (`{ fieldName: FieldDef }`), `label?` |
46
+ | `addField` (`app_addfield`) | `model`, `field`, `definition` (a FieldDef) |
47
+ | `changeFieldType` (`app_changefieldtype`) | `model`, `field`, `definition` (a FieldDef; REST also accepts the older `to`) |
48
+ | `hideField`, `removeField` | `model`, `field` |
49
+ | `renameField` | `model`, `from`, `to` |
50
+ | `removeModel` | `model` |
51
+ | `addMethod` | `model`, `name`, `method` (`{ description, code, params? }`) |
52
+ | `enableRecipe` | `recipe` |
53
+ | `cleanUp` | `keep?` |
54
+ | `importTemplate`, `exportTemplate` | `data`, `includeRecords?` |
55
+
56
+ A FieldDef is `{ type, label?, required?, choices?, ref? }`; `type` is one of
57
+ text, number, integer, boolean, date, money, choice, reference, json. Field names
58
+ are camelCase (`dueDate`).
59
+
60
+ ## Contracts for the UI and the assistant
61
+
62
+ Every route needs the owner's session cookie (401 signed out, 403 not the
63
+ owner). Writes must be same-origin `application/json`. Errors are
64
+ `{ error, code? }`. Live updates: `GET /api/events` (below).
65
+
66
+ | Route | Body | Response |
67
+ |---|---|---|
68
+ | `GET /api/shell/describe` | | `{ name, version, recipes, models: { [Model]: { kind: 'app'\|'shipped-subclass', label, table, className, extends, fields: { [name]: FieldDef }, ownFields: string[], methods: { [name]: { description, params } } } }, pendingApprovals }` |
69
+ | `GET /api/shell/history` | | `{ versions: { version, parent, author, summary, createdAt, purged, current }[] }` oldest first |
70
+ | `POST /api/shell/undo` | none | change result `{ version, summary, statements, snapshot, appRestartMs, ... }`; 409 if purged; **202** `PendingApproval` (with `preview.replaced`, the rows that would be overwritten) when the change being undone converted a field's type |
71
+ | `GET /api/shell/approvals?status=` | | `{ approvals: ApprovalView[], pending }`; `status` is `pending`, `approved`, `denied` or `failed` |
72
+ | `POST /api/shell/approvals/<id>` | `{ approve: boolean }` | `{ id, status: 'approved'\|'denied'\|'failed', result?, error? }`; 404 unknown, 409 already decided |
73
+ | `GET /api/shell/records/<Model>?limit=` | | `{ records: object[] }` |
74
+ | `POST /api/shell/records/<Model>` | the record's fields | 201 the record |
75
+ | `GET /PUT /api/shell/records/<Model>/<id>` | PUT: changed fields | the record |
76
+ | `DELETE /api/shell/records/<Model>/<id>` | | **202** `PendingApproval`; nothing is deleted until approved |
77
+ | `/api/app/<route>` (any method) | as the app's REST | the app's generated REST (`/api/app/chores` is the app's `/api/chores`); `DELETE` answers **202** `PendingApproval`; only the app's own collection routes are served (`sync/apply`, `_changes`, `_events` and unknown collections are 404) |
78
+ | `GET /api/events` | | server-sent events |
79
+ | `POST /mcp` | MCP Streamable HTTP | shell tools plus app tools, one policy |
80
+
81
+ `PendingApproval = { status: 'pending_approval', approvalId, summary, preview }`.
82
+ `ApprovalView = { id, status, summary, preview, action, requestedBy, decidedBy, decidedAt, result, createdAt }`
83
+ where `action` is `{ kind: 'command', command } | { kind: 'tool', name, args } | { kind: 'rest', method, url } | { kind: 'record-delete', model, id }`.
84
+
85
+ `GET /api/events` sends `event: ready`, then `event: shell` with JSON
86
+ `{ type: 'changed', version, summary }` (definition changed: reload
87
+ `describe`, the menu and the tool catalog), `{ type: 'records', model }`
88
+ (`model` may be null: reload lists) and `{ type: 'approval', id, status, summary }`
89
+ (`pending`, `approved`, `denied`, `failed`). A `: ping` comment every 25 s.
90
+
91
+ Builder operations are static methods on `App` and also appear as generated
92
+ REST (`POST /api/apps/<op>`) and MCP tools (`app_definemodel`, `app_addfield`,
93
+ `app_hidefield`, `app_renamefield`, `app_removefield`, `app_changefieldtype`,
94
+ `app_removemodel`, `app_addmethod`, `app_enablerecipe`, `app_undo`,
95
+ `app_cleanup`, `app_exporttemplate`, `app_importtemplate`, `app_describe`,
96
+ `app_history`), plus `approval_pending`. There is no tool to decide an approval: only the
97
+ owner, signed in, decides in the UI, so no model-driven client (the assistant
98
+ or an outside MCP client) can approve its own call. The risky ones
99
+ (`addMethod`, `removeField`, `removeModel`, `changeFieldType`, `cleanUp`, an
100
+ `importTemplate` carrying code) return `PendingApproval` instead of applying.
101
+
102
+ ### Policy module for the assistant
103
+
104
+ `src/lib/server/policy.ts`:
105
+
106
+ - `toolsFor(principal): Promise<CatalogTool[]>`: the merged catalog (`{ name, description, inputSchema, annotations?, side: 'shell'|'app', risky }`); empty unless `principal` is the owner.
107
+ - `authorizeCall(principal, name): Promise<CatalogTool>`: throws `PolicyError` (`status` 401 no principal, 403 not the owner or unknown tool).
108
+ - `callTool(principal, name, args)`: authorize and run. A risky call returns a `PendingApproval`.
109
+ - `Principal = { id, tenantId, scopes? }`; get one from the request with `principalFromLocals(locals)` (`owner.ts`).
110
+
111
+ ## The assistant
112
+
113
+ The dock (`src/lib/assistant/AssistantDock.svelte`, mounted for the owner in
114
+ the layout) talks to `mountAssistantRoutes` (`src/lib/server/assistant/`).
115
+ Owner session required on all of them (401/403); the POST is same-origin.
116
+
117
+ | Route | Body | Response |
118
+ |---|---|---|
119
+ | `GET /api/assistant/status` | | `{ configured: boolean, code?, message? }`; never a key, provider or endpoint. `code` is `assistant_not_configured` (no API key), `assistant_provider_refused` (subscription provider configured) or `ai_unavailable` (inference off) |
120
+ | `GET /api/assistant/threads` | | `{ threads: { id, title, updatedAt }[] }` newest first |
121
+ | `GET /api/assistant/threads/<id>` | | `{ thread, messages: ThreadMessage[] }`; 404 for a missing or foreign thread |
122
+ | `POST /api/assistant/turn` | `{ message, threadId? }` | `text/event-stream`, `event: turn`, JSON `TurnEvent` (below). **503** `{ type: 'unavailable', configured: false, code, error }` when the assistant cannot run; 409 while another turn is running |
123
+
124
+ `TurnEvent` (`src/lib/assistant/events.ts`): `thread {threadId}`, `text {delta}`,
125
+ `tool_call {id,name,args}`, `tool_result {id,name,ok,summary}`,
126
+ `approval_required {id,name,approvalId,summary,preview}`,
127
+ `approval_decided {id,approvalId,status}`, `error {code,message}`,
128
+ `done {stop}` (`complete | max_steps | max_tokens | timeout | aborted | waiting | error`).
129
+ Closing the request (the dock's Stop button, a closed tab) aborts the model
130
+ call, the running tool and any wait.
131
+
132
+ - **Tools.** The model is offered `toolsFor(owner)` (builder tools plus the
133
+ app's generated tools); each call is checked against
134
+ a fresh list and then goes through `callTool` (the same policy as `/mcp`).
135
+ Non-owners get no tools and no assistant. Calls run one at a time, in order.
136
+ - **Risky calls.** A risky call returns a `PendingApproval`; the turn emits
137
+ `approval_required` and is held (`holdForUser`) until the owner decides it
138
+ (the dock's card POSTs `/api/shell/approvals/<id>`, so it goes through
139
+ `Approval.decide`). The model is then told approved, denied or failed. If the
140
+ wait times out or the client leaves, the model is told it is waiting, the
141
+ turn ends as `waiting`/`aborted`, and the Approval stays pending, unapplied.
142
+ The assistant has no way to set the engine's `approved` flag.
143
+ - **Engine.** `@happyvertical/ai` through core's inference tiers
144
+ (`resolveInferenceTarget`, tier `good`, override `SMRT_APP_SHELL_ASSISTANT_TIER`).
145
+ The key comes from the `ai` block of `smrt.config.ts`, `SMRT_AI_*` or the
146
+ provider's own variable (`ANTHROPIC_API_KEY`), read only server side, never
147
+ logged or sent to the browser, until bring-your-own (#4666) lands. Subscription
148
+ providers (`claude-cli`, `chatgpt-plan`) are refused. With no key,
149
+ status/turn answer the typed not-configured state and the dock shows it.
150
+ - **Bounds** (`DEFAULT_LIMITS` in `turn.ts`): 8 model rounds, 2048 output
151
+ tokens per call, 60 000 tokens per turn, 4 calls per round, 60 s per model
152
+ call, 180 s of model/tool time per turn, 15 min per approval wait.
153
+ - **Guidance.** `src/lib/assistant/guidance/system.md` (bundled with `?raw`).
154
+ - **Threads** persist in shell.db (`assistant_threads`, `assistant_messages`).
155
+ - **Tests** inject a stub model (`modelFactory` option, `test-helpers.ts`).
156
+
157
+ ## First-run schema
158
+
159
+ `prepareShellSchema(db)` (`src/lib/server/schema.ts`) creates the tables of every
160
+ registered shell model (and the smrt-users, tenancy and profiles tables the
161
+ runtime needs) when missing, so an empty data directory boots without
162
+ `DB_SCHEMA_MISSING`. It is passed to app-runtime's existing `prepareDatabase`
163
+ option. Dependency: the first-run schema hook of smrt#4835 is pending; the
164
+ shell will switch to it in `smrt.ts` when it lands.
165
+
166
+ ## Develop
167
+
168
+ ```
169
+ pnpm --filter @happyvertical/smrt-app-shell test
170
+ pnpm --filter @happyvertical/smrt-app-shell build
171
+ pnpm --filter @happyvertical/smrt-app-shell typecheck
172
+ ```
173
+
174
+ See [AGENTS.md](AGENTS.md) for the invariants and [agents/engine.md](agents/engine.md) for the engine.
@@ -0,0 +1,85 @@
1
+ # App-shell engine (`src/engine`)
2
+
3
+ Runs a person's own smrt app from a versioned JSON definition. The host
4
+ (`AppHost`) owns the version history (through a `VersionStore`) and the data
5
+ directory; the app itself runs in a worker thread with its own registry and
6
+ `app.db`. Every definition change starts a fresh worker (the registry only
7
+ adds) and swaps it in once it is ready. The engine must not import SvelteKit,
8
+ `$lib`, or anything under `src/lib`.
9
+
10
+ ## Rules
11
+
12
+ - **Approval gate.** `RISKY_COMMANDS`, `isRiskyCommand` and `isRiskyTool` live
13
+ in `commands.ts`. `execute` throws `ApprovalRequiredError` for a risky
14
+ command without `approved: true`; `callAppTool` does the same for
15
+ `<model>_delete`, and `rest` for `DELETE`. Approval covers the command, not a
16
+ plan hash: the plan is recomputed at execute time.
17
+ - **Queue.** Work inside the change queue must call `callApp`, never `onApp`
18
+ (which awaits the queue): the old `clean_up` deadlock.
19
+ - **Migrations** (`app-runtime.ts`) run in one transaction in the new worker,
20
+ reading through the transaction handle. Destructive ones retire the old worker
21
+ first and restart the old definition if the new one fails. They are
22
+ SQLite-shaped (`RENAME/DROP COLUMN`, `VACUUM INTO`, `pragma_table_info`);
23
+ Postgres is future work.
24
+ - **Column storage.** No column-type history: re-adding a field checks the real
25
+ column's storage family. Text defaults can be `''`, so counts and archives use
26
+ `IS NOT NULL AND <> ''`. Renames do not rewrite method code; the plan lists
27
+ `affectedMethods`.
28
+ - **App subclass rule.** `enableRecipe('profiles')` adds the app model
29
+ `Profile` (`extends` the shipped class). Its fields are nullable columns on
30
+ the shipped `profiles` table (STI). Never subclass `ProfileType` (its
31
+ subclass would get its own table). Creating a Profile find-or-creates the real
32
+ `person` `ProfileType`.
33
+ - **Sandbox** (`sandbox.ts`): QuickJS, pure function over a copy of the data.
34
+ Everything crossing the boundary is JSON text. Limits: time, memory, a small
35
+ stack (128 KB), 1000 writes, 1 MB output, 8 MB input. The body is checked as a
36
+ function body by V8 first (QuickJS's own `Function` accepts a body that closes
37
+ the wrapper). Writes are validated and a `VACUUM INTO` snapshot is taken before
38
+ they are applied.
39
+ `setMemoryLimit` does not bound large single allocations in this build, and the
40
+ interrupt handler is polled only every ~10,000 operations, so the WebAssembly
41
+ heap itself is capped (`SANDBOX_HEAP_PAGES`, 128 MB): a memory bomb stops in
42
+ ~0.2 s instead of allocating 2 GB.
43
+
44
+ ## Profile subclass (core gap fixed, #4871)
45
+
46
+ The app subclass of the shipped `Profile` is registered as
47
+ `@local/app:Profile`, the same class name as its parent. Core keeps the two
48
+ apart: `registerRuntimeModel` leaves a same-named parent registered (constructor
49
+ index, no lineage replacement), and the parent's generated REST and MCP
50
+ surfaces are closed with `ObjectRegistry.registerOverride(parent, { api: false,
51
+ mcp: false })` in `AppRuntime.register`, so only the subclass's tools exist.
52
+ Covered by core's `runtime-model.test.ts` and engine `profile-subclass.test.ts`.
53
+
54
+ ## Safety rules added in review
55
+
56
+ - **Undo is gated when it overwrites data.** `undo` is not in `RISKY_COMMANDS`;
57
+ the host decides from the *stored* version (`analyseUndo`): a version with a
58
+ `convertColumn` step restores each row's archived original on the way back,
59
+ replacing values written since, so the plan is `risky` with a read-only
60
+ `countOverwrites` preview (`replaced`, `fields[]`). Every other undo stays
61
+ immediate. The shell pins the approval to that version (`undo.version`); the
62
+ host refuses if the app moved on.
63
+ - **REST surface.** `restRouteAllowed` (definition.ts) lets only
64
+ `/api/<route>` and `/api/<route>/<id|action>` of the app's own models through,
65
+ in `AppHost.rest` and `AppRuntime.rest`. Core's reserved `sync/apply` (its
66
+ `delete` op deletes records), `_changes`/`_events`/`_inference` and the
67
+ registry fallback to shipped classes (e.g. `ProfileType`) are 404 before the
68
+ DELETE gate. `DELETE` on an allowed route still needs approval.
69
+ - **Snapshot retention** (`snapshots.ts`): the worker keeps the newest 20
70
+ `method-*` snapshots after each new one; `cleanUp` removes `migrate-*` and
71
+ `schema-*` snapshots older than the newest change it purges. Only plain files
72
+ matching the engine's own name pattern in `snapshots/` are ever deleted
73
+ (never symlinks, directories or other files).
74
+ - **importTemplate preview** lists every method (model, name, description,
75
+ params, code), so what the owner approves is what is shown; a template with
76
+ more than 200,000 characters of method code is refused as unreviewable.
77
+
78
+ ## Lessons
79
+
80
+ - A worker must not be terminated while `@happyvertical/sql`'s startup HMAC key
81
+ job is pending (Node aborts the process); `app-worker.ts` settles it before
82
+ reporting ready.
83
+ - If V8 overflows its own stack inside WebAssembly, QuickJS cannot recover; the
84
+ small QuickJS stack limit keeps its error first, and a host-level failure
85
+ discards the WASM module instance.
@@ -0,0 +1,138 @@
1
+ import { type AppDefinition, type FieldDef, type Migration, type Step } from './definition.ts';
2
+ import { type MethodOutput, type Write } from './sandbox.ts';
3
+ type Row = Record<string, unknown>;
4
+ export interface BootResult {
5
+ statements: string[];
6
+ snapshot: string | null;
7
+ }
8
+ export declare class AppRuntime {
9
+ readonly def: AppDefinition;
10
+ readonly version: number;
11
+ private db;
12
+ private dataDir;
13
+ private snapshots;
14
+ constructor(dataDir: string, def: AppDefinition, version: number);
15
+ /**
16
+ * Open app.db, register the definition, and bring the tables up to it: the
17
+ * migration's steps (if any) and then the additive changes, all in one
18
+ * transaction. A failure leaves app.db exactly as it was.
19
+ */
20
+ boot(migration?: Migration): Promise<BootResult>;
21
+ /** Register the definition's models in this (fresh) registry. */
22
+ private register;
23
+ private qualified;
24
+ /**
25
+ * Statements that bring tables up to the registered models, reading
26
+ * through `h`. With `run`, each model's statements execute before the next
27
+ * is planned (the subclass of a shipped model plans against its parent's
28
+ * table). Shipped tables come first.
29
+ */
30
+ private plan;
31
+ private existingColumns;
32
+ /** A column that exists with another storage family than its field wants: refuse. */
33
+ private checkColumns;
34
+ private columnTypes;
35
+ private tableExists;
36
+ /** Run one step up or down inside the boot transaction; returns what it did. */
37
+ private runStep;
38
+ /** What a type change would do to existing values, without changing anything. */
39
+ previewConversion(table: string, key: string, column: string, to: FieldDef): Promise<{
40
+ rows: number;
41
+ withValue: number;
42
+ convertible: number;
43
+ failing: {
44
+ id: unknown;
45
+ value: unknown;
46
+ }[];
47
+ failingCount: number;
48
+ }>;
49
+ /** How many non-empty values a column holds (for a removal preview). */
50
+ countValues(table: string, column: string): Promise<number>;
51
+ countRows(table: string): Promise<number>;
52
+ /** How many archived values and tables the given versions hold. */
53
+ countArchives(versions: number[], tables: string[]): Promise<{
54
+ values: number;
55
+ tables: number;
56
+ }>;
57
+ /**
58
+ * How many rows undoing `version`'s `convertColumn` steps would overwrite:
59
+ * rows whose current value is not what converting the archived original
60
+ * gives (they were edited, or created over the original, since).
61
+ */
62
+ countOverwrites(version: number, steps: Step[]): Promise<{
63
+ table: string;
64
+ column: string;
65
+ replaced: number;
66
+ }[]>;
67
+ /** Delete archived values and tables for the given versions. */
68
+ purgeArchives(versions: number[], tables: string[]): Promise<{
69
+ values: number;
70
+ tables: number;
71
+ }>;
72
+ /** A consistent copy of app.db in snapshots/, made with VACUUM INTO. */
73
+ snapshot(kind: string): Promise<string>;
74
+ /** Delete migrate/schema snapshots taken before `beforeMs` (clean up). */
75
+ pruneSnapshots(beforeMs: number): number;
76
+ private mcp;
77
+ private toolNames;
78
+ /** The app's own generated MCP tools: chore_list, chore_complete, ... */
79
+ tools(): Promise<import("@happyvertical/smrt-core").MCPTool[]>;
80
+ callTool(name: string, args: Row): Promise<{
81
+ content: any;
82
+ isError: boolean;
83
+ structuredContent: any;
84
+ }>;
85
+ /** SMRT's generated REST routes. */
86
+ rest(req: {
87
+ method: string;
88
+ url: string;
89
+ body?: string;
90
+ }): Promise<{
91
+ status: number;
92
+ contentType: string;
93
+ body: string;
94
+ }>;
95
+ describe(): {
96
+ name: string;
97
+ version: number;
98
+ recipes: string[];
99
+ models: Record<string, import("./definition.ts").DescribedModel>;
100
+ };
101
+ private modelDef;
102
+ /** Fields a record of `model` carries: built-in ones and visible own ones. */
103
+ private fieldsOf;
104
+ private collection;
105
+ private plain;
106
+ list(model: string, limit?: number): Promise<Row[]>;
107
+ get(model: string, id: string): Promise<Row>;
108
+ /** Check and convert `data` for a write. `refs: false` skips target lookups (imports). */
109
+ private coerce;
110
+ create(model: string, data: Row, opts?: {
111
+ id?: string;
112
+ refs?: boolean;
113
+ }): Promise<Row>;
114
+ update(model: string, id: string, data: Row): Promise<Row>;
115
+ remove(model: string, id: string): Promise<{
116
+ deleted: string;
117
+ }>;
118
+ /** Create records of a template with their ids (references stay valid). */
119
+ importRecords(records: Record<string, Row[]>): Promise<{
120
+ records: number;
121
+ }>;
122
+ /** Find or create the real `person` ProfileType (the type of every app Profile). */
123
+ private personType;
124
+ runMethod(model: string, method: string, id: string | null, params?: Row): Promise<{
125
+ result: unknown;
126
+ writes: never[];
127
+ applied: number;
128
+ snapshot: null;
129
+ } | {
130
+ result: unknown;
131
+ writes: Write[];
132
+ applied: number;
133
+ snapshot: string;
134
+ }>;
135
+ /** Reads only: records exist, required fields are present, references resolve. */
136
+ private checkWrites;
137
+ }
138
+ export type { MethodOutput };