@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.
- package/AGENTS.md +99 -0
- package/CLAUDE.md +1 -0
- package/LICENSE +7 -0
- package/README.md +173 -2
- package/agents/engine.md +85 -0
- package/dist/engine/app-runtime.d.ts +138 -0
- package/dist/engine/app-runtime.js +545 -0
- package/dist/engine/app-worker.d.ts +1 -0
- package/dist/engine/app-worker.js +62 -0
- package/dist/engine/commands.d.ts +94 -0
- package/dist/engine/commands.js +30 -0
- package/dist/engine/definition.d.ts +192 -0
- package/dist/engine/definition.js +497 -0
- package/dist/engine/errors.d.ts +23 -0
- package/dist/engine/errors.js +35 -0
- package/dist/engine/host.d.ts +153 -0
- package/dist/engine/host.js +791 -0
- package/dist/engine/index.d.ts +6 -0
- package/dist/engine/index.js +7 -0
- package/dist/engine/sandbox.d.ts +60 -0
- package/dist/engine/sandbox.js +260 -0
- package/dist/engine/snapshots.d.ts +7 -0
- package/dist/engine/snapshots.js +54 -0
- package/dist/engine/store.d.ts +39 -0
- package/dist/engine/store.js +32 -0
- package/dist/engine/worker-handle.d.ts +29 -0
- package/dist/engine/worker-handle.js +89 -0
- package/dist/engine/worker-protocol.d.ts +22 -0
- package/dist/engine/worker-protocol.js +20 -0
- package/dist/lib/objects/App.d.ts +60 -0
- package/dist/lib/objects/App.js +273 -0
- package/dist/lib/objects/AppVersion.d.ts +22 -0
- package/dist/lib/objects/AppVersion.js +62 -0
- package/dist/lib/objects/Approval.d.ts +39 -0
- package/dist/lib/objects/Approval.js +101 -0
- package/dist/lib/objects/AssistantMessage.d.ts +24 -0
- package/dist/lib/objects/AssistantMessage.js +63 -0
- package/dist/lib/objects/AssistantThread.d.ts +16 -0
- package/dist/lib/objects/AssistantThread.js +44 -0
- package/dist/lib/objects/index.d.ts +5 -0
- package/dist/lib/objects/index.js +5 -0
- package/dist/lib/server/approvals.d.ts +69 -0
- package/dist/lib/server/approvals.js +239 -0
- package/dist/lib/server/builder.d.ts +16 -0
- package/dist/lib/server/builder.js +71 -0
- package/dist/lib/server/events.d.ts +16 -0
- package/dist/lib/server/events.js +14 -0
- package/dist/lib/server/host.d.ts +22 -0
- package/dist/lib/server/host.js +96 -0
- package/dist/lib/server/owner.d.ts +33 -0
- package/dist/lib/server/owner.js +92 -0
- package/dist/lib/server/version-store.d.ts +20 -0
- package/dist/lib/server/version-store.js +115 -0
- package/dist/manifest.json +1680 -0
- package/dist/smrt-knowledge.okf.md +95 -0
- 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
|
-
#
|
|
1
|
+
# @happyvertical/smrt-app-shell
|
|
2
2
|
|
|
3
|
-
|
|
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.
|
package/agents/engine.md
ADDED
|
@@ -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 };
|