@happyvertical/smrt-app-shell 0.60.18 → 0.61.0

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 (52) hide show
  1. package/AGENTS.md +51 -9
  2. package/README.md +378 -23
  3. package/agents/draft-ui.md +74 -0
  4. package/agents/engine.md +81 -0
  5. package/agents/shared-pieces.md +127 -0
  6. package/dist/engine/app-runtime.d.ts +7 -0
  7. package/dist/engine/app-runtime.js +30 -3
  8. package/dist/engine/commands.d.ts +23 -0
  9. package/dist/engine/commands.js +29 -0
  10. package/dist/engine/draft.d.ts +31 -0
  11. package/dist/engine/draft.js +88 -0
  12. package/dist/engine/host.d.ts +175 -1
  13. package/dist/engine/host.js +501 -9
  14. package/dist/engine/index.d.ts +4 -2
  15. package/dist/engine/index.js +2 -1
  16. package/dist/engine/publish.d.ts +100 -0
  17. package/dist/engine/publish.js +218 -0
  18. package/dist/engine/store.d.ts +8 -0
  19. package/dist/engine/store.js +8 -0
  20. package/dist/engine/worker-protocol.d.ts +1 -1
  21. package/dist/engine/worker-protocol.js +1 -0
  22. package/dist/lib/objects/App.d.ts +42 -1
  23. package/dist/lib/objects/App.js +72 -4
  24. package/dist/lib/objects/AppDraft.d.ts +31 -0
  25. package/dist/lib/objects/AppDraft.js +73 -0
  26. package/dist/lib/objects/AppDraftVersion.d.ts +25 -0
  27. package/dist/lib/objects/AppDraftVersion.js +68 -0
  28. package/dist/lib/objects/Approval.d.ts +4 -4
  29. package/dist/lib/objects/Approval.js +3 -3
  30. package/dist/lib/objects/index.d.ts +2 -0
  31. package/dist/lib/objects/index.js +2 -0
  32. package/dist/lib/server/approval-store.d.ts +6 -0
  33. package/dist/lib/server/approval-store.js +102 -0
  34. package/dist/lib/server/approvals.d.ts +15 -0
  35. package/dist/lib/server/approvals.js +62 -43
  36. package/dist/lib/server/builder.d.ts +18 -10
  37. package/dist/lib/server/builder.js +46 -18
  38. package/dist/lib/server/drafts.d.ts +100 -0
  39. package/dist/lib/server/drafts.js +324 -0
  40. package/dist/lib/server/events.d.ts +14 -0
  41. package/dist/lib/server/events.js +3 -0
  42. package/dist/lib/server/host.d.ts +44 -2
  43. package/dist/lib/server/host.js +155 -11
  44. package/dist/lib/server/results.d.ts +6 -0
  45. package/dist/lib/server/results.js +8 -0
  46. package/dist/lib/server/target.d.ts +24 -0
  47. package/dist/lib/server/target.js +64 -0
  48. package/dist/lib/server/version-store.d.ts +40 -1
  49. package/dist/lib/server/version-store.js +144 -32
  50. package/dist/manifest.json +699 -6
  51. package/dist/smrt-knowledge.okf.md +19 -2
  52. package/package.json +19 -15
@@ -0,0 +1,74 @@
1
+ # Draft UI validation
2
+
3
+ The content header owns draft controls on home, model, history and review pages; welcome and settings have none. Chat is hidden during onboarding. The publish preview groups Added, Removed, Changed and Archived items in counted cards before its ordered changes and full method code. The saved owner view selects the
4
+ layout description; loaders and mutations carry that description's explicit
5
+ target. Changing copies remounts forms and returns home. Approvals always name
6
+ live. No report route is present. Shell copy uses the existing plain-text
7
+ pattern (the package has no message catalog).
8
+
9
+ | Behavior | Trigger and positive case | Failure case | Context / executor | Runtime / edge | Level / command |
10
+ |---|---|---|---|---|---|
11
+ | Content controls | Owner starts draft; count, switch, actions, chat badge | Unpublishable draft disables switching/review; discard remains | Owner browser, shell routes | Svelte 5 / draft events | DOM / package test |
12
+ | Explicit copy | Switch persists view; description drives reads and writes | Failed switch keeps rendered copy; other-copy record events ignored | Owner, server event.fetch and browser fetch | Node 26 + jsdom / REST and MCP targets | Node + DOM / package test |
13
+ | Draft records | Draft delete 200 navigates away | Live delete 202 waits for approval | Owner, existing server gate | SQLite through server (unchanged) | DOM / package test |
14
+ | Navigation | Draft-only model has new badge | Live lists only live models | Owner layout | jsdom / describe | DOM / package test |
15
+ | Review | Full preview, all code and archive counts | Chain change before request or approval offers review again | Owner gesture, pinned Approval | jsdom / preview + publish + decide | DOM / package test |
16
+ | History | Publish versions grouped, undo-to uses first parent | Purged/current boundaries disable undo; 202 links to approvals | Owner, existing undo transaction | Node 26 + jsdom / history | Node + DOM / package test |
17
+ | Lifecycle | Started/published/discarded/unpublishable refresh dependencies | Unknown events ignored; reconnect refreshes all | Owner SSE subscription | jsdom / EventSource | DOM + Node / package test |
18
+ | Live data survives | Define, fork, edit, write live, publish, undo, discard | Draft test rows excluded on publish | Owner HTTP session, real engine | Node 26 + SQLite / production server | Manual / README Run it |
19
+
20
+ Database/auth contracts and transactions are unchanged; their negative cases
21
+ remain covered by the existing server suites. This is a new UI, so a bug-fix
22
+ base comparison is not applicable. No containers or external services are needed.
23
+
24
+ Manual execution (Node 26.12.1, production adapter-node build, port 4821):
25
+ `smrt app setup` with a fresh real path under `/private/tmp`, then `node build`.
26
+ Created the owner through the setup form. Authenticated curl calls defined Chore,
27
+ seeded a live row, converted points and removed obsolete in the draft, added Note
28
+ and a code method, and added separate live/draft records after the fork. The
29
+ review UI showed full code and archive counts. Changing the chain after review
30
+ refused Publish and offered Review again. Publishing through its Approval kept
31
+ the new live row (points converted to 8), excluded the draft-only row, and tagged
32
+ all five versions. History's Undo this publish raised an Approval; approving
33
+ restored the text field and archived obsolete value and retained the live row.
34
+ Started another draft and confirmed Discard. At 390×844, the content controls
35
+ wrapped without horizontal overflow and retained the warning; the new marker
36
+ used the warning token. Stopped the server by PID and removed session cookies
37
+ and temporary runtime data. No browser page errors were observed.
38
+
39
+ ## Desktop QA regressions
40
+
41
+ All rows below use the signed-in owner, Node 26 and in-process SQLite where
42
+ persistence is involved; no transaction or schema behavior changes. DOM cases
43
+ run with `vitest run -c vitest.dom.config.ts`, server cases with `vitest run`.
44
+ The package `test` command runs both. Each regression is checked against its
45
+ pre-fix implementation as well as the fixed code.
46
+
47
+ | Behavior | Reachable trigger / positive | Negative / failure | Executor / edge | Level |
48
+ |---|---|---|---|---|
49
+ | Assistant readiness | Connect Claude Code or ChatGPT, save key; open composer updates | Sign out disables composer; failed requests preserve known state | Browser connection controls and status route; no credentials in events | DOM |
50
+ | Onboarding chat | Welcome or unfinished onboarding hides panel | Finished/skipped onboarding restores saved chat choice; plugin stays hidden | Layout server data and browser layout | DOM |
51
+ | Draft bar routes | Home, model list/record, history and review show controls | Welcome and all settings pages hide controls | Browser pathname | DOM |
52
+ | Per-turn mode | Start draft after existing conversation, change viewed copy, resume CLI | No draft after discard/publish replaces stale draft context | Stub model and Claude Code/Codex subprocesses; same tool scopes | Node |
53
+ | Tool summaries | Builder summary and describe/list phrase | No raw JSON, SQL or snapshot paths; unknown tools get generic phrase | API and CLI events, stored thread replay, HTTP responses | Node + DOM |
54
+ | Attribution | Owner id resolves to profile name; recorded assistant and engine remain | Unknown identity uses neutral label; spacing survives DOM rendering | Session profile, approval cards and history | DOM |
55
+ | Phone navigation | 375px starts on content, drawer can open on demand | Desktop expanded preference never opens phone drawer; title can ellipsize | Shared shell responsive state | DOM + browser |
56
+ | Publish review | Added/Removed/Changed/Archived groups, counts, ordered changes, full code | Empty groups show zero; conversions and archives remain visible | Existing preview payload; UI primitives and tokens | DOM |
57
+
58
+ QA rerun (2026-11-10, Node 26.12.1, production `node build`, fresh data
59
+ outside the checkout, port 4822, desktop owner-link auto-sign-in): native Chrome
60
+ showed welcome without chat or draft controls. Selecting Claude Code and finishing
61
+ Fresh opened an enabled composer saying “Using Claude Code” without a reload.
62
+ Settings had no draft controls. Seeded Product and OldModel, started a draft from
63
+ the UI, then added Supplier, a notes field and a method, removed a field/model,
64
+ and converted money to integer through the authenticated routes. Review showed
65
+ Added 3 / Removed 2 / Changed 1 / Archived 3, then six ordered changes and full
66
+ method code. Its approval said “Asked just now by” the owner's profile name;
67
+ after approval, History showed that name and retained “engine” on version 0.
68
+ At Chrome's 375×812 responsive viewport, a fresh page load showed content,
69
+ the full app name and bottom tabs with the drawer closed. Menu opened and closed
70
+ it; returning to desktop restored the expanded sidebar. The console was empty.
71
+ Real paid model turns, ChatGPT OAuth and real key saves were not exercised;
72
+ connection transitions and resumed-engine inputs were verified with DOM tests,
73
+ the stub model and both stub CLIs. Each numbered fix has failing pre-fix
74
+ regression evidence and passing fixed-code evidence. No containers were used.
package/agents/engine.md CHANGED
@@ -75,6 +75,85 @@ Covered by core's `runtime-model.test.ts` and engine `profile-subclass.test.ts`.
75
75
  params, code), so what the owner approves is what is shown; a template with
76
76
  more than 200,000 characters of method code is refused as unreviewable.
77
77
 
78
+ ## Drafts (draft then publish)
79
+
80
+ A draft is a second `AppHost` over a COPY of the live data in its own
81
+ directory; publishing replays its changes onto the live data. The engine side
82
+ is documented here; the shell's tables, approvals and UI sit on top.
83
+
84
+ - **Frozen host.** `HostOptions.frozen` / `host.setFrozen(bool)`: `plan` and
85
+ `execute` of every command in `STRUCTURAL_COMMANDS` (all but
86
+ `exportTemplate`, incl. `undo`, `cleanUp`, `importTemplate`) throw
87
+ `EngineError('refused')`; an unknown command type counts as structural.
88
+ `STRUCTURAL_COMMANDS` is a total `Record<CommandType, boolean>`, so a new
89
+ command must pick a side. Records, reports, the app's tools and REST keep
90
+ working. The shell freezes the live host while a draft is open.
91
+ - **`autoApprove`** (risky commands, `_delete` tools, REST `DELETE`) is read
92
+ once at construction into a `#private` field: no setter. `AppHost.open`
93
+ refuses it unless the directory holds the engine's draft marker
94
+ (`.smrt-draft.json`, written only by `forkDraft`) naming a different live
95
+ directory. A live directory never has a marker.
96
+ - **`live.forkDraft(draftDir, { store })`** (queue, calls drained): worker op
97
+ `copyTo(path)` is `VACUUM INTO` (one consistent state, no -wal/-shm needed).
98
+ The call creates `draftDir` (must not exist, must not be/contain/lie inside
99
+ live), copies `app.db`, writes the marker, seeds the EMPTY draft store with
100
+ a copy of live's current record (the draft's BASE, same version number; not
101
+ a draft version) and returns a draft host (`host.draft` is the marker,
102
+ `autoApprove` on). Failure removes only the directory it just created. A
103
+ draft can neither fork nor `undo` below its base. Draft versions are
104
+ numbered `maxVersion + 1` in the draft store. `draft.discard()` closes the
105
+ host and deletes its directory, only if it holds the marker, is not a symlink
106
+ and does not contain the live directory (`discardDraftDirectory(dir)` does the
107
+ same for an orphan). A restart reopens a draft with
108
+ `AppHost.open(dir, { store, autoApprove: true })`.
109
+ - **Publish** (`publish.ts` helpers, `host.ts` orchestration). `live.previewPublish(draft)`
110
+ returns a `PublishPreview`: per-version steps and SQL, definition diff, archive
111
+ counts and EVERY method in full, from a rehearsal on a fresh copy of the CURRENT
112
+ live data (live only waits while the copy is taken); `digest` pins the draft's
113
+ chain. `live.publishDraft(draft, { author, approved: true, expect: digest, draftName? })`
114
+ refuses without `approved`, without the digest, if the chain or the app it
115
+ started from changed, or if a `.prepublish` file is lying around. Inside the
116
+ queue, calls drained: copy live, replay each draft version in order (one short
117
+ worker per version, its stored definition and steps, the number it will get in
118
+ live; so add-then-rename works), write the result as ONE file (`VACUUM INTO`),
119
+ copy the old data to `snapshots/<ts>-0-migrate-prepublish.db`, write
120
+ `publish-journal.json`, retire the live worker, rename `app.db` (+ `-wal`,
121
+ `-shm`, `-journal`; main file last) to `<name>.prepublish`, rename the new file
122
+ in, start the worker, write the versions to the store, then remove journal,
123
+ asides and rehearsal. The versions are N ordinary live versions numbered after
124
+ the store's HIGHEST (live may sit on an undone branch), parent-chained,
125
+ summaries tagged `[draft: name] ...`; archives are keyed by those numbers so
126
+ `undo` works. Draft records are not carried. Any failure before the store is
127
+ written renames the asides back (live's `app.db` is byte-for-byte what it
128
+ was, same version) and restarts the old app; `onStage` lets tests fail each
129
+ stage. Use a store with the optional atomic `commit(records, current)`; without
130
+ it a failure mid-append leaves unreachable orphan versions. The queue is held
131
+ until then, so nothing writes to the new file before the commit.
132
+ - **Boot reconcile** (`reconcilePublish`, every `AppHost.open`, before the app
133
+ starts; result in `host.recovery`): a journal whose `finalVersion` is the
134
+ store's current only needs its leftovers removed; any other journal is ROLLED
135
+ BACK (asides restored, the new file removed), not forward: nothing could have
136
+ written to the new file, and the draft stays open to publish again. An
137
+ unreadable journal refuses to start rather than guess. Stray
138
+ `publish-rehearsal-*` directories are removed.
139
+ - **`undoTo`** is a command (`{ type: 'undoTo', version, from? }`, structural, so
140
+ frozen hosts refuse it): the same step as `undo` repeated newest first for
141
+ every version after `version` on the current path, one plan, one decision.
142
+ It is `risky` (approval) when any of them has a `convertColumn` step, with the
143
+ summed `countOverwrites` preview; a purged version in the span refuses it
144
+ before anything changes. Each step is its own consistent state, so a failure
145
+ part-way leaves the app at the version reached and the error says
146
+ "undid k of n". `from` pins the current version as `undo`'s pin does.
147
+ - **Undo over a re-added name.** Undoing an add or a define keeps its column or
148
+ table (data is never dropped by an undo). So when a removal is undone after
149
+ the same field or model was added again, the archived values go back into the
150
+ column that is there, and an EMPTY table of the model's name is dropped so the
151
+ archived table (with its rows) can return; a table holding rows is never
152
+ dropped and the undo stops with the database's own message.
153
+ - **Queued calls.** `onApp` (records, tools, REST) waits until the change queue
154
+ is idle and stable, then runs in the same synchronous step; it counts
155
+ in-flight calls so a change can wait for them (needed for consistent copies).
156
+
78
157
  ## Lessons
79
158
 
80
159
  - A worker must not be terminated while `@happyvertical/sql`'s startup HMAC key
@@ -83,3 +162,5 @@ Covered by core's `runtime-model.test.ts` and engine `profile-subclass.test.ts`.
83
162
  - If V8 overflows its own stack inside WebAssembly, QuickJS cannot recover; the
84
163
  small QuickJS stack limit keeps its error first, and a host-level failure
85
164
  discards the WASM module instance.
165
+
166
+ S1 shared Connect AI compatibility: the shell client/owner onboarding route forwards `cancel` to shared `chatgpt-cancel`. Unsupported auth SDK cancellation remains an error, not a fake successful sign-out. Existing owner/origin checks and shared readiness status are retained.
@@ -0,0 +1,127 @@
1
+ # Shared pieces (Tricia F1)
2
+
3
+ Tricia keeps its assistant panel, routes, thread models/store and draft/builder
4
+ policy. Engine adapters already use smrt-inference (`engines.md`).
5
+
6
+ Tool chips use smrt-chat's browser-safe `./tool-summary` entry. The shell module
7
+ is only a policy adapter: `recordLabels: 'generic'` preserves "Checked records"
8
+ and `plainTextSummary` preserves safe free-text labels in saved threads. Shared
9
+ credential, SQL, path and payload guards still apply. Default chat labels do
10
+ not change.
11
+
12
+ ## Test design
13
+
14
+ | Behavior/invariant | Trigger | Positive | Failure | Actor/context | Executor/transaction | Runtime/dialect | External edge | Level | Command |
15
+ | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
16
+ | Shared tool labels | Both engines and saved-thread replay | Existing words and shared function | JSON, SQL, paths, credentials excluded | Existing owner/thread context | No mutation | Node/browser | Chat browser-safe entry | Unit + existing DOM | app-shell test; chat test |
17
+ | Approval compatibility | Owner requests/decides a risky live action | Original identity, preview, history and one effect | Non-owner, duplicate, invalid action, restart/partial failure | Live verified owner; draft policy stays shell-owned | Shell SQLite + shared authority CAS | Node/SQLite | Runtime authority and chat hold/card | Integration + DOM | app-shell test |
18
+ | Connection compatibility | Welcome/settings connect action | Existing engine readiness and model preference | Non-owner, origin, unavailable engine, upstream failure | Verified current owner | Existing assistant preference writes | Node/browser | Shared ConnectAi + route mount | Integration + DOM | app-shell test |
19
+
20
+ ## Reuse check
21
+
22
+ MCP `review_smrt_project` reviewed app-shell, chat, app-runtime and smrt-svelte;
23
+ `build_context` architecture query covered tool labels, durable holds/cards and
24
+ engine connection/onboarding. Read the developer-intent index. Name and purpose
25
+ `rg` searches over smrt-ui, smrt-svelte, chat, core, app-runtime, approvals and
26
+ inference found the shared tool-summary, OwnerApprovalAuthority, approval bridge,
27
+ AssistantApprovalCard, ConnectAi and mountConnectAiRoutes. The server exposes
28
+ architecture/model context, but no standalone component/intent/recipe search.
29
+ No parallel parser or tool-label implementation is introduced.
30
+
31
+ ## Compatibility gaps closed
32
+
33
+ Onboarding GET delegates readiness to `mountConnectAiRoutes.GET`, backed by
34
+ inference's `createAgentCliService` and locked-command preflight, like the
35
+ cookbook host. Tricia adds its onboarding preference and safe version/account
36
+ presentation; those fields never decide readiness. Its browser `ToolInfo` reuses
37
+ the shared ConnectAiState tool shape rather than maintaining a reduced copy.
38
+ Only successful preflight says Ready in this app (with the version when known).
39
+ A signed-in CLI that cannot start remains unselectable with its fixed reason.
40
+ The real fixture-backed route test verifies ready → broken locked command,
41
+ GET/configuration/status refusal, no path disclosure and no billing fallback.
42
+ Both mounted Tricia variants cover the refused state; the existing composer
43
+ selection/sign-out/saved-key flow is retained. Positive DOM snapshots model the
44
+ server's explicit preflight success instead of implying ready from sign-in.
45
+
46
+ Reuse for this correction: MCP project reviews of app-shell, smrt-svelte and
47
+ template, the intent index, and name/purpose searches found the existing shared
48
+ status handler, inference CLI service, DTO and translated status messages. No new
49
+ component, helper, model, type definition or route file was added.
50
+
51
+ - Tool words: generic `_list`/`_search`/`_get` results stay "Checked records";
52
+ arbitrary safe saved summary text remains readable. Both are chat options.
53
+ - Durable holds: Tricia has no expiry. Runtime's explicit `nonExpiring` option
54
+ requires a store advertising `supportsNonExpiring`; the canonical store still
55
+ refuses it. The legacy single-tenant storage adapter retains all rows and ids,
56
+ translates accepted/executing/completed states and uses database CAS rather
57
+ than a process-local in-flight set. No schema change or data migration.
58
+ - Waits: chat's `observeAssistantApproval` waits for a host-completed result;
59
+ it cannot decide, consume or execute. Runtime `wait({ settled: true })`
60
+ observes pending/approved/executing until terminal. Abort/timeout retain rows.
61
+ - Approval UI: the shared card's optional host-authorized presentation retains
62
+ the heading, Waiting/Approved wording, timestamps, errors, disabled controls
63
+ and callbacks. Its `detail` snippet carries shell-owned builder/code/draft
64
+ previews and the publish retry link; that policy stays in Tricia.
65
+ - Connection UI: `presentation="compact"` retains Claude Code + ChatGPT on
66
+ Welcome, Codex under Advanced in Settings, version/account text, existing
67
+ legal/advanced links, success messages and auto-selection after a login the
68
+ page started. Optional `modelState`/`chooseModel` use the existing shell
69
+ model-preference routes. One shared request/poll/abort lifecycle handles both
70
+ presentations; the browser-safe `./connect-ai/client` exports the pure words.
71
+ - Connection routes: `allowUnconfiguredApiSelection` preserves choosing API
72
+ without a key; `includeSignOutConfirmation` preserves the old response.
73
+ Both default off. The shell maps its established JSON endpoints to the
74
+ shared actions, retains owner/origin checks, model validation, Fresh/Skip
75
+ and sign-out preference policy. No assistant route/thread replacement.
76
+
77
+ ## New-file reuse evidence
78
+
79
+ `src/lib/server/approval-store.ts` is the only new production helper file.
80
+ MCP review/context and `rg` approval/owner/hold/storage/legacy/tenant searches
81
+ found runtime's existing `OwnerApprovalStore` extension seam but no adapter
82
+ for Tricia's `approvals` schema. It implements that seam over the existing
83
+ `ApprovalCollection`; decision, consumption, result bounds and waiting reuse the
84
+ shared authority. The legacy model remains solely for installed storage and its
85
+ saved generated API contract. `approval-store.test.ts` and
86
+ `components/shared-pieces.dom.test.ts` introduce only test-local fixtures and
87
+ exercise the real shared exports. No new component/model/route/tool file.
88
+
89
+ Hindsight recall was attempted for `package:app-shell`; the configured tool
90
+ reported offline. Canonical adapter documentation is retained here instead.
91
+
92
+ Template definitions can exceed the shared plain-envelope nesting and 128 KB
93
+ limits. The adapter passes the validated action and preview as inert JSON (the existing
94
+ column contracts), and opts into a 4 MB envelope budget for the shell's bounded
95
+ HTTP/template input plus preview. Shared default limits remain unchanged.
96
+ The only root config edit removes the stale raw-pixel allowance for the old
97
+ card's removed screen-reader CSS; the shared card uses button accessible names.
98
+
99
+
100
+ The nested draft-preview regression fails before the JSON-envelope adapter and
101
+ passes after it: field defaults may themselves contain nested JSON and must
102
+ remain visible exactly as reviewed. The shared authority does not interpret
103
+ that host policy data or relax its plain-data depth guard.
104
+
105
+
106
+ The old publish-review flow also depends on exact decision-button text. The
107
+ shared Button historically inserts a spacer before its label. MCP review on
108
+ smrt-ui and name/purpose searches for Button label/whitespace/text contracts
109
+ found no opt-out. Its additive `labelWhitespace="trim"` option preserves the
110
+ old shell text; the default `preserve` output is tested unchanged for buttons
111
+ and links. The shared approval card alone opts into trimming. This is the
112
+ small additional owning-package edit in `smrt-ui` (Button plus its existing
113
+ golden tests); no new component/helper/model file is added.
114
+
115
+
116
+ C4 OpenAI follow-up: shared Connect AI now lists Codex after recommended ChatGPT
117
+ in welcome and settings, preserving the same preflight-backed Tricia handler,
118
+ model preference and composer refresh. DOM expectations retain real ready status
119
+ and assert the shared order instead of the superseded settings-only disclosure.
120
+ Codex containment is owned by inference (`agents/engines.md`), never a shell shim.
121
+
122
+ `pnpm test` first runs `svelte-kit sync`: the DOM suite needs the generated
123
+ `.svelte-kit/tsconfig.json` even when Turbo restores only built output. A cold
124
+ checkout without that config otherwise fails in dependency optimization before
125
+ any component test runs (Rolldown's runtime cannot resolve `node:module`).
126
+ The test script prepares the config for both the Node and DOM suites; it does
127
+ not depend on a preceding uncached build.
@@ -71,6 +71,13 @@ export declare class AppRuntime {
71
71
  }>;
72
72
  /** A consistent copy of app.db in snapshots/, made with VACUUM INTO. */
73
73
  snapshot(kind: string): Promise<string>;
74
+ /**
75
+ * A consistent, standalone copy of app.db at `path` (VACUUM INTO: one
76
+ * statement, so it sees a single committed state, and it needs no -wal or
77
+ * -shm file next to it). Refuses a path that exists. The host calls it with
78
+ * no change running and no call in flight.
79
+ */
80
+ copyTo(path: string): Promise<string>;
74
81
  /** Delete migrate/schema snapshots taken before `beforeMs` (clean up). */
75
82
  pruneSnapshots(beforeMs: number): number;
76
83
  private mcp;
@@ -3,8 +3,8 @@
3
3
  //
4
4
  // Migrations here are SQLite-shaped (RENAME/DROP COLUMN, VACUUM INTO,
5
5
  // pragma_table_info). Postgres is future work.
6
- import { mkdirSync } from 'node:fs';
7
- import { join } from 'node:path';
6
+ import { existsSync, mkdirSync } from 'node:fs';
7
+ import { isAbsolute, join } from 'node:path';
8
8
  import { ObjectRegistry, planRuntimeModelDDL, registerRuntimeModel, } from '@happyvertical/smrt-core';
9
9
  import { MCPGenerator } from '@happyvertical/smrt-core/generators/mcp';
10
10
  import { APIGenerator } from '@happyvertical/smrt-core/generators/rest';
@@ -157,6 +157,16 @@ export class AppRuntime {
157
157
  await exec(`ALTER TABLE ${q(step.table)} RENAME COLUMN ${q(up ? step.from : step.to)} TO ${q(up ? step.to : step.from)}`);
158
158
  break;
159
159
  case 'archiveTable':
160
+ // Undoing a removal puts the archived table back. A model that was
161
+ // defined again since leaves an EMPTY table of that name (undoing a
162
+ // define keeps its table): drop it so the original, with its rows,
163
+ // can return. A table with rows is never dropped; the rename then
164
+ // fails and says so.
165
+ if (!up &&
166
+ (await this.tableExists(tx, step.table)) &&
167
+ (await tx.query(`SELECT 1 FROM ${q(step.table)} LIMIT 1`)).rows
168
+ .length === 0)
169
+ await exec(`DROP TABLE ${q(step.table)}`);
160
170
  await exec(`ALTER TABLE ${q(up ? step.table : step.archivedAs)} RENAME TO ${q(up ? step.archivedAs : step.table)}`);
161
171
  break;
162
172
  case 'dropColumn':
@@ -167,7 +177,10 @@ export class AppRuntime {
167
177
  await exec(`ALTER TABLE ${q(step.table)} DROP COLUMN ${q(step.column)}`);
168
178
  }
169
179
  else {
170
- await exec(`ALTER TABLE ${q(step.table)} ADD COLUMN ${q(step.column)} ${sqlType(step.type)}`);
180
+ // Undoing an add keeps its column, so a field added again after the
181
+ // removal is still there: put the archived values back into it.
182
+ if (!(await this.columnTypes(tx, step.table)).has(step.column))
183
+ await exec(`ALTER TABLE ${q(step.table)} ADD COLUMN ${q(step.column)} ${sqlType(step.type)}`);
171
184
  await exec(`UPDATE ${q(step.table)} SET ${q(step.column)} = (SELECT value FROM _archive a WHERE a.version = ? AND a.tbl = ? AND a.col = ? AND a.record_id = ${q(step.table)}.${q(step.key)})`, [m.version, step.table, step.column]);
172
185
  await exec(`DELETE FROM _archive WHERE version = ? AND tbl = ? AND col = ?`, [m.version, step.table, step.column]);
173
186
  }
@@ -293,6 +306,20 @@ export class AppRuntime {
293
306
  pruneMethodSnapshots(join(this.dataDir, 'snapshots'));
294
307
  return file;
295
308
  }
309
+ /**
310
+ * A consistent, standalone copy of app.db at `path` (VACUUM INTO: one
311
+ * statement, so it sees a single committed state, and it needs no -wal or
312
+ * -shm file next to it). Refuses a path that exists. The host calls it with
313
+ * no change running and no call in flight.
314
+ */
315
+ async copyTo(path) {
316
+ if (typeof path !== 'string' || !isAbsolute(path))
317
+ throw new AppError('copyTo needs an absolute path');
318
+ if (existsSync(path))
319
+ throw new AppError(`${path} already exists`);
320
+ await this.db.query(`VACUUM INTO '${path.replace(/'/g, "''")}'`);
321
+ return path;
322
+ }
296
323
  /** Delete migrate/schema snapshots taken before `beforeMs` (clean up). */
297
324
  pruneSnapshots(beforeMs) {
298
325
  return pruneMigrationSnapshots(join(this.dataDir, 'snapshots'), Number(beforeMs));
@@ -46,6 +46,18 @@ export type Command = {
46
46
  | {
47
47
  type: 'undo';
48
48
  version?: number;
49
+ }
50
+ /**
51
+ * Undo every version after `version` (an ancestor of the current one) in one
52
+ * command, newest first. It needs approval when any of them has a
53
+ * `convertColumn` step, decided by the host from the stored versions.
54
+ * `from` pins the version the caller saw as current; the host refuses if the
55
+ * app has moved on.
56
+ */
57
+ | {
58
+ type: 'undoTo';
59
+ version: number;
60
+ from?: number;
49
61
  } | {
50
62
  type: 'cleanUp';
51
63
  keep: number;
@@ -90,5 +102,16 @@ export declare function overwritesOnUndo(step: {
90
102
  }): boolean;
91
103
  /** Static risk of a command. `undo` is decided by the host from the stored version. */
92
104
  export declare function isRiskyCommand(cmd: Command): boolean;
105
+ /**
106
+ * Which commands change the app's definition, tables or history. A frozen host
107
+ * (see `HostOptions.frozen`) refuses every one of them; only `exportTemplate`
108
+ * reads. It is a total `Record` so that a new command cannot be added without
109
+ * deciding which side it is on.
110
+ */
111
+ export declare const STRUCTURAL_COMMANDS: Readonly<Record<CommandType, boolean>>;
112
+ /** True unless the command is known to be read-only (an unknown type counts as structural). */
113
+ export declare function isStructuralCommand(cmd: {
114
+ type?: unknown;
115
+ }): boolean;
93
116
  /** Generated app tools that delete records need approval. */
94
117
  export declare function isRiskyTool(name: string): boolean;
@@ -24,6 +24,35 @@ export function isRiskyCommand(cmd) {
24
24
  return Object.values(cmd.data?.definition?.models ?? {}).some((m) => Object.keys(m?.methods ?? {}).length > 0);
25
25
  return false;
26
26
  }
27
+ /**
28
+ * Which commands change the app's definition, tables or history. A frozen host
29
+ * (see `HostOptions.frozen`) refuses every one of them; only `exportTemplate`
30
+ * reads. It is a total `Record` so that a new command cannot be added without
31
+ * deciding which side it is on.
32
+ */
33
+ export const STRUCTURAL_COMMANDS = {
34
+ defineModel: true,
35
+ addField: true,
36
+ hideField: true,
37
+ renameField: true,
38
+ removeField: true,
39
+ changeFieldType: true,
40
+ removeModel: true,
41
+ addMethod: true,
42
+ enableRecipe: true,
43
+ undo: true,
44
+ undoTo: true,
45
+ cleanUp: true,
46
+ exportTemplate: false,
47
+ importTemplate: true,
48
+ };
49
+ /** True unless the command is known to be read-only (an unknown type counts as structural). */
50
+ export function isStructuralCommand(cmd) {
51
+ const type = cmd?.type;
52
+ return !(typeof type === 'string' &&
53
+ Object.hasOwn(STRUCTURAL_COMMANDS, type) &&
54
+ STRUCTURAL_COMMANDS[type] === false);
55
+ }
27
56
  /** Generated app tools that delete records need approval. */
28
57
  export function isRiskyTool(name) {
29
58
  return /_delete$/.test(name);
@@ -0,0 +1,31 @@
1
+ export declare const DRAFT_MARKER_FILE = ".smrt-draft.json";
2
+ export interface DraftMarker {
3
+ kind: 'smrt-app-draft';
4
+ format: 1;
5
+ /** The live data directory this draft was copied from (real path). */
6
+ liveDir: string;
7
+ /** The live version the draft started from. */
8
+ baseVersion: number;
9
+ createdAt: string;
10
+ }
11
+ /** A path as the file system sees it; the plain resolved path while it does not exist. */
12
+ export declare function realDir(p: string): string;
13
+ export declare function sameDir(a: string, b: string): boolean;
14
+ /** The marker of a draft directory, or null for an ordinary (live) one. */
15
+ export declare function readDraftMarker(dataDir: string): DraftMarker | null;
16
+ /** Engine-internal: nothing outside the engine may make a directory a draft. */
17
+ export declare function writeDraftMarker(dataDir: string, liveDir: string, baseVersion: number): DraftMarker;
18
+ /** True when `inner` is `outer` or lies under it. */
19
+ export declare function isInside(inner: string, outer: string): boolean;
20
+ /**
21
+ * Where a new draft may go: a path that does not exist yet, whose parent does
22
+ * (or can), that is neither the live directory nor inside it nor contains it
23
+ * (a discard deletes the whole draft directory, so it must never reach live).
24
+ */
25
+ export declare function checkDraftPath(draftDir: string, liveDir: string): string;
26
+ /**
27
+ * Delete a draft directory. Only a directory that holds the draft marker (so
28
+ * one the engine made), is not a symlink and does not contain the live
29
+ * directory it names. Nothing else is ever removed.
30
+ */
31
+ export declare function removeDraftDirectory(dataDir: string): void;
@@ -0,0 +1,88 @@
1
+ // Drafts: a draft is a second app host over a COPY of the live data, in its own
2
+ // directory next to the live one. The directory carries a marker file that only
3
+ // the engine writes; it is what makes a directory a draft (and therefore what
4
+ // allows `autoApprove`, and what lets a discard delete it).
5
+ import { existsSync, lstatSync, readFileSync, realpathSync, rmSync, writeFileSync, } from 'node:fs';
6
+ import { dirname, join, resolve, sep } from 'node:path';
7
+ import { EngineError } from "./errors.js";
8
+ export const DRAFT_MARKER_FILE = '.smrt-draft.json';
9
+ /** A path as the file system sees it; the plain resolved path while it does not exist. */
10
+ export function realDir(p) {
11
+ try {
12
+ return realpathSync(p);
13
+ }
14
+ catch {
15
+ return resolve(p);
16
+ }
17
+ }
18
+ export function sameDir(a, b) {
19
+ return realDir(a) === realDir(b);
20
+ }
21
+ /** The marker of a draft directory, or null for an ordinary (live) one. */
22
+ export function readDraftMarker(dataDir) {
23
+ const file = join(dataDir, DRAFT_MARKER_FILE);
24
+ if (!existsSync(file))
25
+ return null;
26
+ let m;
27
+ try {
28
+ m = JSON.parse(readFileSync(file, 'utf8'));
29
+ }
30
+ catch {
31
+ throw new EngineError(`${DRAFT_MARKER_FILE} in ${dataDir} is unreadable; remove the draft directory`, 'refused');
32
+ }
33
+ if (m?.kind !== 'smrt-app-draft' ||
34
+ m.format !== 1 ||
35
+ typeof m.liveDir !== 'string' ||
36
+ !Number.isInteger(m.baseVersion))
37
+ throw new EngineError(`${DRAFT_MARKER_FILE} in ${dataDir} is not a draft marker`, 'refused');
38
+ return m;
39
+ }
40
+ /** Engine-internal: nothing outside the engine may make a directory a draft. */
41
+ export function writeDraftMarker(dataDir, liveDir, baseVersion) {
42
+ const marker = {
43
+ kind: 'smrt-app-draft',
44
+ format: 1,
45
+ liveDir: realDir(liveDir),
46
+ baseVersion,
47
+ createdAt: new Date().toISOString(),
48
+ };
49
+ writeFileSync(join(dataDir, DRAFT_MARKER_FILE), JSON.stringify(marker), {
50
+ flag: 'wx',
51
+ });
52
+ return marker;
53
+ }
54
+ /** True when `inner` is `outer` or lies under it. */
55
+ export function isInside(inner, outer) {
56
+ const a = realDir(inner);
57
+ const b = realDir(outer);
58
+ return a === b || a.startsWith(b.endsWith(sep) ? b : b + sep);
59
+ }
60
+ /**
61
+ * Where a new draft may go: a path that does not exist yet, whose parent does
62
+ * (or can), that is neither the live directory nor inside it nor contains it
63
+ * (a discard deletes the whole draft directory, so it must never reach live).
64
+ */
65
+ export function checkDraftPath(draftDir, liveDir) {
66
+ const abs = resolve(draftDir);
67
+ const target = join(realDir(dirname(abs)), abs.slice(dirname(abs).length));
68
+ if (isInside(target, liveDir) || isInside(liveDir, target))
69
+ throw new EngineError('the draft directory must be separate from the live data directory', 'refused');
70
+ if (existsSync(abs))
71
+ throw new EngineError(`${abs} already exists`, 'refused');
72
+ return abs;
73
+ }
74
+ /**
75
+ * Delete a draft directory. Only a directory that holds the draft marker (so
76
+ * one the engine made), is not a symlink and does not contain the live
77
+ * directory it names. Nothing else is ever removed.
78
+ */
79
+ export function removeDraftDirectory(dataDir) {
80
+ const marker = readDraftMarker(dataDir);
81
+ if (!marker)
82
+ throw new EngineError(`${dataDir} is not a draft directory, so it is not removed`, 'refused');
83
+ if (lstatSync(dataDir).isSymbolicLink())
84
+ throw new EngineError(`${dataDir} is a symlink; not removed`, 'refused');
85
+ if (isInside(marker.liveDir, dataDir))
86
+ throw new EngineError(`${dataDir} contains the live data directory; not removed`, 'refused');
87
+ rmSync(dataDir, { recursive: true, force: true });
88
+ }