@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.
- package/AGENTS.md +51 -9
- package/README.md +378 -23
- package/agents/draft-ui.md +74 -0
- package/agents/engine.md +81 -0
- package/agents/shared-pieces.md +127 -0
- package/dist/engine/app-runtime.d.ts +7 -0
- package/dist/engine/app-runtime.js +30 -3
- package/dist/engine/commands.d.ts +23 -0
- package/dist/engine/commands.js +29 -0
- package/dist/engine/draft.d.ts +31 -0
- package/dist/engine/draft.js +88 -0
- package/dist/engine/host.d.ts +175 -1
- package/dist/engine/host.js +501 -9
- package/dist/engine/index.d.ts +4 -2
- package/dist/engine/index.js +2 -1
- package/dist/engine/publish.d.ts +100 -0
- package/dist/engine/publish.js +218 -0
- package/dist/engine/store.d.ts +8 -0
- package/dist/engine/store.js +8 -0
- package/dist/engine/worker-protocol.d.ts +1 -1
- package/dist/engine/worker-protocol.js +1 -0
- package/dist/lib/objects/App.d.ts +42 -1
- package/dist/lib/objects/App.js +72 -4
- package/dist/lib/objects/AppDraft.d.ts +31 -0
- package/dist/lib/objects/AppDraft.js +73 -0
- package/dist/lib/objects/AppDraftVersion.d.ts +25 -0
- package/dist/lib/objects/AppDraftVersion.js +68 -0
- package/dist/lib/objects/Approval.d.ts +4 -4
- package/dist/lib/objects/Approval.js +3 -3
- package/dist/lib/objects/index.d.ts +2 -0
- package/dist/lib/objects/index.js +2 -0
- package/dist/lib/server/approval-store.d.ts +6 -0
- package/dist/lib/server/approval-store.js +102 -0
- package/dist/lib/server/approvals.d.ts +15 -0
- package/dist/lib/server/approvals.js +62 -43
- package/dist/lib/server/builder.d.ts +18 -10
- package/dist/lib/server/builder.js +46 -18
- package/dist/lib/server/drafts.d.ts +100 -0
- package/dist/lib/server/drafts.js +324 -0
- package/dist/lib/server/events.d.ts +14 -0
- package/dist/lib/server/events.js +3 -0
- package/dist/lib/server/host.d.ts +44 -2
- package/dist/lib/server/host.js +155 -11
- package/dist/lib/server/results.d.ts +6 -0
- package/dist/lib/server/results.js +8 -0
- package/dist/lib/server/target.d.ts +24 -0
- package/dist/lib/server/target.js +64 -0
- package/dist/lib/server/version-store.d.ts +40 -1
- package/dist/lib/server/version-store.js +144 -32
- package/dist/manifest.json +699 -6
- package/dist/smrt-knowledge.okf.md +19 -2
- 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
|
-
|
|
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;
|
package/dist/engine/commands.js
CHANGED
|
@@ -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
|
+
}
|