akanjs 3.0.0 → 3.0.2-rc.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/CHANGELOG.md +3670 -0
- package/base/base.ts +1 -1
- package/client/cookie.ts +4 -6
- package/client/csrTypes.ts +2 -2
- package/common/Logger.ts +10 -0
- package/common/index.ts +0 -1
- package/common/routeConvention.ts +1 -9
- package/dictionary/base.dictionary.ts +7 -0
- package/dictionary/trans.ts +7 -1
- package/document/documentQuery.ts +6 -1
- package/document/index.ts +1 -0
- package/document/into.ts +15 -7
- package/document/liveRowScope.ts +79 -0
- package/fetch/client/fetchClient.ts +64 -12
- package/fetch/client/httpClient.ts +13 -4
- package/index.ts +22 -0
- package/package.json +1 -1
- package/server/akanOption.ts +20 -1
- package/server/akanServer.ts +5 -0
- package/server/cachePolicy.ts +12 -2
- package/server/crawler.ts +6 -0
- package/server/head.tsx +0 -4
- package/server/mcp/McpAuth.ts +3 -2
- package/server/mcp/McpExecutionContext.ts +15 -14
- package/server/mcp/McpRateLimiter.ts +18 -67
- package/server/proxy/webProxyRunner.ts +2 -0
- package/server/resolver/database.resolver.ts +28 -13
- package/server/resolver/signal.resolver.ts +75 -35
- package/server/robots.ts +1 -23
- package/server/routeTreeBuilder.ts +4 -2
- package/server/rscWorker.tsx +12 -24
- package/server/rscWorkerHost.ts +16 -7
- package/server/ssrFromRscRenderer.tsx +19 -15
- package/server/ssrTypes.ts +9 -0
- package/server/webRouter.ts +9 -2
- package/service/index.ts +1 -0
- package/service/predefinedAdaptor/index.ts +1 -0
- package/service/predefinedAdaptor/rateLimit.adaptor.ts +15 -0
- package/service/predefinedAdaptor/sql/SqlDocumentStore.ts +119 -27
- package/service/predefinedAdaptor/sql/types.ts +14 -12
- package/service/rateLimiter.ts +80 -0
- package/signal/base.signal.ts +7 -6
- package/signal/endpointRateLimit.ts +159 -0
- package/signal/index.ts +1 -0
- package/signal/middleware.ts +6 -5
- package/signal/serializer/fetch.serializer.ts +15 -4
- package/signal/signalContext.ts +148 -67
- package/signal/slice.ts +30 -9
- package/signal/types.ts +8 -0
- package/test/signalTestRuntime.ts +2 -1
- package/types/base/base.d.ts +1 -1
- package/types/client/cookie.d.ts +1 -1
- package/types/client/csrTypes.d.ts +2 -2
- package/types/common/Logger.d.ts +2 -0
- package/types/common/index.d.ts +1 -1
- package/types/common/routeConvention.d.ts +0 -2
- package/types/dictionary/base.dictionary.d.ts +1 -1
- package/types/dictionary/dictionary.d.ts +8 -8
- package/types/document/documentQuery.d.ts +5 -1
- package/types/document/index.d.ts +1 -0
- package/types/document/into.d.ts +7 -7
- package/types/document/liveRowScope.d.ts +9 -0
- package/types/fetch/client/fetchClient.d.ts +9 -1
- package/types/index.d.ts +32 -0
- package/types/server/akanOption.d.ts +7 -1
- package/types/server/cachePolicy.d.ts +2 -1
- package/types/server/crawler.d.ts +1 -0
- package/types/server/head.d.ts +0 -3
- package/types/server/mcp/McpExecutionContext.d.ts +4 -1
- package/types/server/mcp/McpRateLimiter.d.ts +2 -6
- package/types/server/rscWorkerHost.d.ts +3 -0
- package/types/server/ssrFromRscRenderer.d.ts +3 -1
- package/types/server/ssrTypes.d.ts +8 -0
- package/types/service/index.d.ts +1 -0
- package/types/service/predefinedAdaptor/index.d.ts +1 -0
- package/types/service/predefinedAdaptor/rateLimit.adaptor.d.ts +10 -0
- package/types/service/predefinedAdaptor/sql/SqlDocumentStore.d.ts +6 -10
- package/types/service/predefinedAdaptor/sql/types.d.ts +13 -17
- package/types/service/rateLimiter.d.ts +24 -0
- package/types/signal/endpointRateLimit.d.ts +37 -0
- package/types/signal/index.d.ts +1 -0
- package/types/signal/signalContext.d.ts +33 -15
- package/types/signal/slice.d.ts +17 -5
- package/types/signal/types.d.ts +8 -0
- package/types/test/signalTestRuntime.d.ts +1 -1
- package/types/ui/recipe/badgeRecipe.d.ts +2 -2
- package/types/ui/recipe/buttonRecipe.d.ts +4 -4
- package/types/ui/recipe/inputRecipe.d.ts +3 -3
- package/types/vendor/@akanjs/native/packages/cli/src/config.d.ts +33 -0
- package/types/vendor/@akanjs/native/packages/cli/src/lib/bplist.d.ts +11 -0
- package/types/vendor/@akanjs/native/packages/cli/src/lib/dmg.d.ts +59 -0
- package/types/vendor/@akanjs/native/packages/cli/src/lib/dsStore.d.ts +26 -0
- package/types/vendor/@akanjs/native/packages/cli/src/lib/macAlias.d.ts +24 -0
- package/types/vendor/@akanjs/native/packages/cli/src/lib/macossigning.d.ts +0 -1
- package/types/vendor/@akanjs/native/packages/cli/src/lib/project.d.ts +3 -1
- package/types/vendor/@akanjs/native/packages/cli/src/platforms/linux-appimage.d.ts +6 -1
- package/types/vendor/@akanjs/native/packages/desktop/src/deeplinks.d.ts +9 -2
- package/types/webkit/RenderFailure.d.ts +6 -0
- package/types/webkit/useFrameRuntime.d.ts +0 -1
- package/ui/Input.tsx +7 -1
- package/ui/recipe/buttonRecipe.ts +1 -0
- package/ui/recipe/inputRecipe.ts +13 -7
- package/ui/styles.css +1 -0
- package/vendor/@akanjs/native/native/desktop/src/lib.rs +8 -1
- package/vendor/@akanjs/native/native/macos/dmg/background.html +18 -0
- package/vendor/@akanjs/native/native/macos/dmg/background.png +0 -0
- package/vendor/@akanjs/native/native/macos/dmg/background@2x.png +0 -0
- package/vendor/@akanjs/native/packages/cli/src/config.ts +23 -0
- package/vendor/@akanjs/native/packages/cli/src/lib/addons.ts +2 -1
- package/vendor/@akanjs/native/packages/cli/src/lib/bplist.ts +91 -0
- package/vendor/@akanjs/native/packages/cli/src/lib/configkeys.ts +9 -0
- package/vendor/@akanjs/native/packages/cli/src/lib/dmg.ts +331 -0
- package/vendor/@akanjs/native/packages/cli/src/lib/dsStore.ts +191 -0
- package/vendor/@akanjs/native/packages/cli/src/lib/exec.ts +11 -1
- package/vendor/@akanjs/native/packages/cli/src/lib/macAlias.ts +101 -0
- package/vendor/@akanjs/native/packages/cli/src/lib/macossigning.ts +1 -28
- package/vendor/@akanjs/native/packages/cli/src/lib/project.ts +6 -1
- package/vendor/@akanjs/native/packages/cli/src/platforms/linux-appimage.ts +17 -7
- package/vendor/@akanjs/native/packages/cli/src/platforms/linux.ts +6 -1
- package/vendor/@akanjs/native/packages/cli/src/platforms/macos.ts +5 -2
- package/vendor/@akanjs/native/packages/desktop/src/deeplinks.ts +33 -10
- package/vendor/@akanjs/native/packages/desktop/src/host.ts +1 -1
- package/vendor/use-agentic/CHANGELOG.md +663 -0
- package/webkit/CsrRouteTable.ts +0 -2
- package/webkit/RenderFailure.tsx +32 -0
- package/webkit/RenderLayer.tsx +22 -2
- package/webkit/useCsrValues.ts +21 -11
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,3675 @@
|
|
|
1
1
|
# akanjs
|
|
2
2
|
|
|
3
|
+
## 3.0.1
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 4871ae5: The default `robots.txt` no longer disallows AI crawlers. GPTBot, ClaudeBot, PerplexityBot, Google-Extended and the rest may read public pages like any other crawler; `/api` (the configured prefix), `/_akan`, `/admin`, `/manager` and `/private` stay disallowed. An app that wants to keep AI crawlers out puts its own `robots.txt` in `public/`, which is served ahead of the generated one.
|
|
8
|
+
|
|
9
|
+
### Patch Changes
|
|
10
|
+
|
|
11
|
+
- 4871ae5: Send crawlers the whole page. A request whose user agent names a search engine, an AI crawler or a link-preview fetcher (Googlebot, GPTBot, ClaudeBot, Yeti, facebookexternalhit and the like) now waits for every Suspense boundary and gets each one rendered in place. Before, it got the Loading fallback with the content parked in hidden segments that only a script reveals — and even `ssr: "block"` left a large boundary there. Browsers keep streaming, and the HTML result cache keeps the crawler page apart from the browser page.
|
|
12
|
+
- 4871ae5: Drop the `robots.txt.tsx` special route. `/robots.txt` was always answered first by `public/robots.txt` or the generated file, so the page never rendered; a `robots.txt.tsx` file is now an ordinary page under the locale, and `isSpecialRouteLeaf` is gone from `akanjs/common`. Change the file by putting your own `robots.txt` in `public/`.
|
|
13
|
+
|
|
14
|
+
## 3.0.0
|
|
15
|
+
|
|
16
|
+
### Minor Changes
|
|
17
|
+
|
|
18
|
+
- ccf2ae2: A CSR dev page — `?csr=true`, `/__csr`, and every native dev build — updates in place on save instead of reloading.
|
|
19
|
+
|
|
20
|
+
- `akan start` serves the CSR page as a module registry: each module is its own factory, and a save sends only the
|
|
21
|
+
modules it changed. A component edit keeps hook and DOM state (React Fast Refresh), a store edit keeps the store,
|
|
22
|
+
and a build error shows the overlay and recovers without a reload.
|
|
23
|
+
- A page or layout edit swaps that route in place through `replacePages`: the page stack, history and stores stay,
|
|
24
|
+
and so does the state of the components under the edited page or layout. A `*.constant.ts`, an added or removed
|
|
25
|
+
route, a new npm dependency or a signal/dictionary change still reloads.
|
|
26
|
+
- `AKAN_DEV_CSR=artifact` brings back the single-file dev bundle, which reloads on every save. `akan build` and a
|
|
27
|
+
release mobile build are unchanged.
|
|
28
|
+
- HMR sockets now say what they are: a CSR page connects as `/_akan/hmr?client=csr` and gets `csr-update` instead of
|
|
29
|
+
the SSR `reload` and `rsc-refresh`, and a page that missed an update while disconnected reloads
|
|
30
|
+
when it reconnects.
|
|
31
|
+
|
|
32
|
+
- ccf2ae2: A Windows desktop app can share its screen without the picker
|
|
33
|
+
|
|
34
|
+
`native.desktop.screenCapture: "auto"` in `akan.config.ts`, or `desktop.screenCapture` on one target, makes the
|
|
35
|
+
Windows app answer the page's `getDisplayMedia()` with the first screen at once, without the picker and without a user
|
|
36
|
+
gesture, for remote support on a screen nobody attends. It is Chromium's switch for automated media tests and covers
|
|
37
|
+
every media request, so leave it off in an app whose pages ask for a camera or a microphone. The default `"picker"`
|
|
38
|
+
keeps the picker; macOS and Linux ignore it.
|
|
39
|
+
|
|
40
|
+
- ccf2ae2: A page a native shell serves reaches its API, and keeps its session, without the cookies it no longer has.
|
|
41
|
+
|
|
42
|
+
The shell serves the page from `app://localhost` (iOS, macOS, Linux) or `https://app.localhost` (Android, Windows),
|
|
43
|
+
which is cross-origin to the API and names neither its host nor its port, and WKWebView keeps no cookies for it.
|
|
44
|
+
|
|
45
|
+
- `CrossSiteGuard` admits those two origins instead of `capacitor://localhost`, `ionic://localhost` and
|
|
46
|
+
`http://localhost`, and every signal route answers `OPTIONS` for an allowlisted origin with the verbs it serves,
|
|
47
|
+
so a bearer header or a JSON body preflights without an ingress in front. Never with
|
|
48
|
+
`access-control-allow-credentials`: the `SameSite=None` cookie would let an allowed origin ride a browser session.
|
|
49
|
+
- `AKAN_PUBLIC_SERVER_URL` (http or https) names a CSR bundle's server — host, port and protocol, the websocket
|
|
50
|
+
URL following it. A cloud CSR bundle without it calls its cloud host on 443; SSR tabs and servers ignore it.
|
|
51
|
+
- A CSR client's session is the bearer token `fetch` sends: `getAuthToken()` reads it before the cookie jar and
|
|
52
|
+
`setAuth()` no longer copies it into a cookie. An `app://` page keeps its other cookies (theme,
|
|
53
|
+
`prepareUserId`, …) in a `localStorage` jar. The `CapacitorCookies` mirror is gone.
|
|
54
|
+
- `libs/shared`: a CSR client keeps each scope's refresh token (user, admin) in storage and sends it in the body,
|
|
55
|
+
which the server already accepted; sign-in, activation and refresh keep the rotated token, sign-out drops it, and one
|
|
56
|
+
refresh per scope runs at a time, since a second use of a rotated token reads as theft and ends every session.
|
|
57
|
+
|
|
58
|
+
**Breaking for a Capacitor build still in the field:** its `capacitor://localhost` origin is refused.
|
|
59
|
+
|
|
60
|
+
- ccf2ae2: A native app can sign in through the system browser and hear the callback on every desktop OS.
|
|
61
|
+
|
|
62
|
+
- `akanjs/client/native` exports `authSession` and its `AuthSessionApi` type, the runtime's system-browser sign-in,
|
|
63
|
+
and `isNativeShell()`, true in any native shell; `isNativeApp()` stays iOS and Android only.
|
|
64
|
+
- `getServerOrigin()` in `akanjs/base` is the server's origin as a browser outside the page opens it. For a page the
|
|
65
|
+
native dev gateway served, that is the dev server (`http://localhost:<dev port>`), not the app origin the page
|
|
66
|
+
calls; otherwise it is the origin of `getEnv().serverHttpUri`.
|
|
67
|
+
- A native dev build on Android reverses the dev server's port as well as the gateway's, so a browser on the device
|
|
68
|
+
reaches it.
|
|
69
|
+
- A target with `deepLinks.schemes` ships `single-instance`. Windows and Linux open a link by starting the app again,
|
|
70
|
+
and the hand-over is how the link reaches the running app. A second launch of such an app now hands over and exits
|
|
71
|
+
instead of opening a second window, and a desktop debug build keeps the release app id, so it does the same while
|
|
72
|
+
the release app runs.
|
|
73
|
+
- In an app with basePaths whose only target names none, `start-*` and `build-*` without `--target` ask for a basePath
|
|
74
|
+
instead of opening the root, which has no CSR page.
|
|
75
|
+
|
|
76
|
+
- ccf2ae2: A push for the page on screen is not shown in front, and `usePurchase` verifies with the app's own function.
|
|
77
|
+
|
|
78
|
+
- The push plugin's `setForegroundPresentation` takes `except: { key, values }`: a push whose `data[key]` is one of
|
|
79
|
+
`values` is not shown while the app is in front, and still arrives as `received`. The CSR frame keeps it set to
|
|
80
|
+
the page on screen, so the chat room being read does not banner its own messages; `data.url` may spell the page
|
|
81
|
+
with or without the locale and the target's basePath.
|
|
82
|
+
- `usePurchase({ verify })` checks a purchase with the app's function instead of `POST <url>/billing/verifyBilling`.
|
|
83
|
+
It gets the normalized proof and the transaction, whose `verification` carries the store's own; what it resolves
|
|
84
|
+
reaches `onPay`/`onSubscribe`, and `null`, `undefined` or `false` refuses. The finish flow is unchanged.
|
|
85
|
+
|
|
86
|
+
- ccf2ae2: The app's websocket refuses a page from another site
|
|
87
|
+
|
|
88
|
+
A socket has no CORS, so a page on any site could open `/api/ws` on a server it reached — a desktop app's loopback
|
|
89
|
+
server through the user's own browser — and read every room it may subscribe to, riding the `SameSite=None` auth
|
|
90
|
+
cookie where there is one. The upgrade now runs `CrossSiteGuard`'s origin check, the one mutations and the dev HMR
|
|
91
|
+
socket already pass: a same-site page, the native shells (`app://localhost`, `https://app.localhost`), an origin in
|
|
92
|
+
`allowedOrigins` and a caller that sends no `Origin` connect as before, and any other origin is refused with 403.
|
|
93
|
+
`option.setCrossSite({ enabled: false })` turns it off with the rest of the gate.
|
|
94
|
+
|
|
95
|
+
**Breaking for a web client on another site that only read and subscribed:** its socket is refused until its origin is
|
|
96
|
+
in `allowedOrigins`, as its mutations already needed.
|
|
97
|
+
|
|
98
|
+
- 8c31d54: The in-page chat guards the model's window, and answers a refusal for length by compacting once
|
|
99
|
+
|
|
100
|
+
Compaction used to watch one number: the transcript, estimated at four characters a token, against `compact.at`
|
|
101
|
+
(24k). It did not know the window, did not count the tools, the screen context or the instructions that ride every
|
|
102
|
+
turn, and read Hangul — nearer one character a token than four — as a fraction of what it costs. Measured on a
|
|
103
|
+
single live DeepSeek turn: the transcript estimate said 23 tokens where the provider counted 458. And when the
|
|
104
|
+
provider refused a prompt too long for its window, the turn simply failed.
|
|
105
|
+
|
|
106
|
+
**The relay now reports what it knows.** Every turn's `done` carries the provider's own count (`usage: { input,
|
|
107
|
+
output }`, `input` the whole prompt) and what the adaptor knows about its model (`limits: { window, output }`).
|
|
108
|
+
The window is declared with `option.setLlm({ contextWindow })` — a table of models would be a claim about models
|
|
109
|
+
that ship after it. `LlmAdaptor` gains an optional `limits`; `AnthropicLlm` reports the answer ceiling it always
|
|
110
|
+
sends, `OpenaiLlm` sends none and so reports none.
|
|
111
|
+
|
|
112
|
+
**The session compacts on whichever trigger comes first.** `compact.at` stays, with its 24k default, as a ceiling
|
|
113
|
+
on what each turn costs — the relay resends the whole transcript every turn and the app pays for each one. Beside
|
|
114
|
+
it, once the window is known, a guard compacts when the prompt passes `window − answer ceiling (8,192 when
|
|
115
|
+
unreported) − compact.buffer (13,000)`, measured by the provider's count of the last turn plus the estimate for
|
|
116
|
+
what arrived since. `{ at: Infinity }` leaves only the guard; `{ at: 0 }` still turns all of it off. The count is
|
|
117
|
+
kept on the assistant message (`ChatMessage.usage`), never sent, and dropped from what a compaction keeps.
|
|
118
|
+
|
|
119
|
+
**A refusal for length is answered once.** Adaptors build refusals through `LlmOverflow.refusal(host, status,
|
|
120
|
+
reason)`, which reads each provider's own "prompt too long" sentence as `agent.error.contextOverflow` with the
|
|
121
|
+
`limit` it named. The relay flags it `overflow` on the wire; the session drops the empty draft, compacts, sends the
|
|
122
|
+
same turn again and remembers the window. A second refusal in the same send fails with the translated message.
|
|
123
|
+
An app's own adaptor that throws through `LlmOverflow.refusal` recovers the same way.
|
|
124
|
+
|
|
125
|
+
The chat header now reads `~31k / 107k tokens` — the estimate against whichever trigger is nearer — with the point
|
|
126
|
+
it compacts at in its tooltip.
|
|
127
|
+
|
|
128
|
+
## Upgrading
|
|
129
|
+
|
|
130
|
+
Nothing is required. To turn the guard on, declare the window your model has:
|
|
131
|
+
|
|
132
|
+
```ts
|
|
133
|
+
option.setLlm({
|
|
134
|
+
apiKey,
|
|
135
|
+
model: "deepseek-flash",
|
|
136
|
+
host: "https://api.deepseek.com",
|
|
137
|
+
contextWindow: 1_000_000,
|
|
138
|
+
});
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
A backend of your own that speaks the turn wire (use-agentic `WIRE.md`) opts in by adding `usage` and `limits` to
|
|
142
|
+
its `done` event and `overflow: { limit? }` to a refusal for length; one that sends neither keeps working as before.
|
|
143
|
+
The usage, limits and overflow fields ride the streaming answer — which is what `httpRunner` negotiates — and are
|
|
144
|
+
not added to the relay's single-JSON `AgentTurn` answer.
|
|
145
|
+
|
|
146
|
+
- 5f53462: A message carries the data the user pointed at
|
|
147
|
+
|
|
148
|
+
An in-page agent could be handed files but not data. What was on the screen reached the model only as the turn's
|
|
149
|
+
`ContextBlock`s, which are reassembled from the screen every turn — so a record named three turns ago is not in the
|
|
150
|
+
conversation any more, and there was nowhere to say _which_ record, or which field of it, somebody meant.
|
|
151
|
+
|
|
152
|
+
`ChatMessage.references` is that place, beside `attachments` and for the same reason: what a person referred to
|
|
153
|
+
while asking is part of the asking. A `MessageReference` names the host's own `refName`, the id, a label, and
|
|
154
|
+
optionally a dotted `path` into the document, and carries the `value` those resolved to.
|
|
155
|
+
|
|
156
|
+
Three properties are load-bearing and deliberate:
|
|
157
|
+
|
|
158
|
+
- **The value is a snapshot.** It is what the data was when the message was sent, never re-read later. Re-reading
|
|
159
|
+
would rewrite what the person was looking at when they spoke — and the common case is an agent that then edits
|
|
160
|
+
the very field it was pointed at, which would leave the reference showing the result with no record of what was
|
|
161
|
+
being changed from. `refName`/`refId`/`path` travel so a tool can read the current value when the answer needs it.
|
|
162
|
+
- **The value arrives masked, and nothing downstream can mask it again.** Masking needs the model class, which no
|
|
163
|
+
wire carries, so whichever model the host names when it stages a reference is the whole of the decision about
|
|
164
|
+
what leaves the browser.
|
|
165
|
+
- **It is bounded at 20,000 characters per reference, at both ends.** A reference is bulkier than a tool result and
|
|
166
|
+
outlives one: it rides every turn from the moment it is sent and is the last thing compaction folds, because
|
|
167
|
+
folding what the user pointed at is folding the question. `Reference.clipped` bounds it as it is staged, and
|
|
168
|
+
`AgentService.referenceLimit` bounds it again where nothing can route around it.
|
|
169
|
+
|
|
170
|
+
`AgentSession` holds the staging slot — `stage` / `staged` / `unstage(key)` / `clearStaged()` — rather than the
|
|
171
|
+
composer, which is where staged files live. The difference is not an inconsistency: a file only ever arrives from
|
|
172
|
+
the composer's own picker or drop zone, while a reference arrives from whichever component drew the data, reaching
|
|
173
|
+
the session it is already inside. Unstaging is keyed on `refName/refId#path`, never on an index, because what
|
|
174
|
+
orders the references of a message is its text.
|
|
175
|
+
|
|
176
|
+
The server folds them into the message text in one place (`AgentService.referenced`), under a heading that says the
|
|
177
|
+
values are a snapshot. Text is the one field every provider mapping already reads, so Anthropic, the OpenAI dialect
|
|
178
|
+
and the text-only default all carry references with no change between them and none of them can drop one quietly.
|
|
179
|
+
A string value prints as itself rather than as escaped JSON, which is the common case and the readable one.
|
|
180
|
+
|
|
181
|
+
Persistence keeps the pointer and drops the value, with a note saying so — a restored conversation re-reads what
|
|
182
|
+
was pointed at with a tool instead of guessing from the label, which is a better answer than a restored attachment
|
|
183
|
+
can give.
|
|
184
|
+
|
|
185
|
+
- 5f53462: The composer can point at data, from the menu or from the screen
|
|
186
|
+
|
|
187
|
+
Builds the user-facing half of message references on top of the carrier. There are two entry points, and which one
|
|
188
|
+
applies depends on who knows the value.
|
|
189
|
+
|
|
190
|
+
`<Agent.Chat reference={[…]} />` takes a `ReferenceSource` per kind of document — `refName`, a `type` in
|
|
191
|
+
`st.expose`'s vocabulary, the app's own `search`, and a `resolve` — and the composer's `@` menu offers whole
|
|
192
|
+
documents from them. Which documents somebody may point at is the app's answer, so the source brings the query; the
|
|
193
|
+
framework brings the token, the masking and the snapshot. The token is written the moment a row is picked and the
|
|
194
|
+
value is staged when `resolve` lands, so a slow fetch never freezes the menu and one that fails leaves a pointer
|
|
195
|
+
rather than a chip that means nothing.
|
|
196
|
+
|
|
197
|
+
`useAgentReference()` is the other half, for a field _inside_ a document. The component drawing it already holds
|
|
198
|
+
the value, so it hands that over with no round trip — and it is the only thing that knows a rich-text field stored
|
|
199
|
+
as `field(Any)` reads as a paragraph rather than as the editor document it is stored as. It no-ops with a warning
|
|
200
|
+
outside a session, the call `AgentValue.publishable` already makes: a card carrying a reference button must not
|
|
201
|
+
cost a route its render.
|
|
202
|
+
|
|
203
|
+
**The token in the draft is what carries a reference; the chip only draws it.** Deleting the token by hand drops
|
|
204
|
+
the reference exactly as removing the chip does, because removing the chip removes the token. Nothing re-derives a
|
|
205
|
+
value from edited text, so a token pasted out of an earlier message travels as a pointer with a note — the same
|
|
206
|
+
shape a restored conversation produces.
|
|
207
|
+
|
|
208
|
+
Staging lives on the session rather than the composer, because `useAgentReference` is called from components the
|
|
209
|
+
composer cannot see. Writing the token is still the composer's, so the session holds the unwritten ones as state
|
|
210
|
+
and a chat acknowledges each by key: two chats on one session — a responsive app rendering a desktop and a mobile
|
|
211
|
+
composer — each hold their own draft and each need the token, which a queue somebody drains would have given to
|
|
212
|
+
whichever rendered first. `refer` warns instead of staging silently when no chat is mounted on that session, which
|
|
213
|
+
is the case where a card sits outside the `<Agent.Zone>` its chat is inside.
|
|
214
|
+
|
|
215
|
+
References also ride the parking queue, so one pointed at while a turn is running survives to the message it
|
|
216
|
+
opens; taking a parked message back restores its values, and anything pointed at since wins, because the parked
|
|
217
|
+
value is the older read of the same field.
|
|
218
|
+
|
|
219
|
+
- 5f53462: A chat's transcript has a turn in it
|
|
220
|
+
|
|
221
|
+
`AgentSteps` is a new `_overrides.tsx` slot holding one agent turn: the messages between the user message that
|
|
222
|
+
opened it and the next one, plus `isRunning` for whether more are still coming. It is the grain a chat needs to
|
|
223
|
+
fold a turn's steps into a `details` and stand its final answer outside them — and the one boundary no per-message
|
|
224
|
+
slot can see, because neither message on either side of it knows it is at an edge.
|
|
225
|
+
|
|
226
|
+
The transcript now emits per turn rather than per message. A user message still stands alone as its own `Bubble`;
|
|
227
|
+
everything after it goes to one `AgentSteps` until the next user message, which also means a transcript whose head
|
|
228
|
+
is a compaction summary opens a turn of its own. The array handed over is what the turn _renders_ rather than what
|
|
229
|
+
it holds on the wire: a tool message whose results a call row already draws is gone, and one holding results no
|
|
230
|
+
call claims is narrowed to those, so an app folding a turn never re-derives the call/result pairing.
|
|
231
|
+
|
|
232
|
+
**The default adds nothing.** It draws the same flat bubbles into a Fragment, not a box, so it takes no
|
|
233
|
+
`className` and no existing layout can tell the component is there. `DefaultSteps` is exported from `akanjs/ui`
|
|
234
|
+
beside the other defaults, for a skin that wants to compose the one it is replacing.
|
|
235
|
+
|
|
236
|
+
`isRunning` is only ever true of the last turn of a transcript the session is working on. Without it the same
|
|
237
|
+
messages read the same whether the agent is mid-step or finished, and a scaffold cannot tell a live progress line
|
|
238
|
+
from a completed turn's header.
|
|
239
|
+
|
|
240
|
+
- ccf2ae2: `akan build-desktop --installer` makes a Windows setup program
|
|
241
|
+
|
|
242
|
+
On Windows the build adds `<file>-<version>-<arch>-setup.exe`, made with NSIS (`winget install NSIS.NSIS`). It installs
|
|
243
|
+
for the current user under `%LOCALAPPDATA%\Programs\<app name>`, where the updates plugin swaps the app without an
|
|
244
|
+
administrator, with a Start menu shortcut and an uninstall entry. `/S` installs silently and `/RUN` starts the app
|
|
245
|
+
afterwards, and a PC without the WebView2 Runtime gets it. The program is not code-signed yet.
|
|
246
|
+
|
|
247
|
+
- What can fail without touching the installed app comes first: WebView2, then the new files, unpacked beside the
|
|
248
|
+
folder. Only then is a copy of the app running from the folder stopped, found by its path, and the folder swapped by
|
|
249
|
+
two renames. A swap that fails puts the old folder back, and with `/RUN` the app left in place starts again.
|
|
250
|
+
|
|
251
|
+
- The uninstaller lives beside the app folder (`<folder>.uninstall.exe`), not in the folder an update replaces, so
|
|
252
|
+
Settings › Apps can still remove an updated app. It finds the folder from the uninstall entry and removes it with
|
|
253
|
+
whatever updates left beside it (`.previous`, unpacked or failed releases) and itself, the launch-at-login entry and
|
|
254
|
+
the updates state; the server's data stays. An uninstaller that cannot be
|
|
255
|
+
written there (a `/D=` right under a drive root) fails the install.
|
|
256
|
+
- The Start menu shortcut and the app `/RUN` or the last page starts work in `%LOCALAPPDATA%`: Windows cannot rename a
|
|
257
|
+
folder some process works in, which is what applying an update does.
|
|
258
|
+
- `/RUN` starts the app after a silent install only; an interactive one offers it on its last page.
|
|
259
|
+
- Run again without `/D=`, it installs into the folder the uninstall entry names, so a reinstall replaces the copy
|
|
260
|
+
already installed wherever it was put, and a setup started while another one runs refuses to start.
|
|
261
|
+
- A silent install that cannot finish exits with 2: files it could not write (the script is long-path aware, and the
|
|
262
|
+
build warns when the deepest file nears 260 characters under `%LOCALAPPDATA%\Programs`), a WebView2 Runtime still
|
|
263
|
+
missing after its setup ran (an interactive install asks), or a `/D=` folder holding someone else's files.
|
|
264
|
+
- An update the app confirms sets the version Settings › Apps shows.
|
|
265
|
+
|
|
266
|
+
- ccf2ae2: `akan.config.ts` reaches the native settings a store release needs, and an app keeps a different id on each store.
|
|
267
|
+
|
|
268
|
+
- `native.android.push` (channel, `smallIcon` relative to the app folder, `color`) and `native.ios.privacy` (the iOS
|
|
269
|
+
privacy manifest) pass through, per target too.
|
|
270
|
+
- `native.icon` takes `{ image, backgroundColor }` and `native.splash` the runtime's `{ image, backgroundColor,
|
|
271
|
+
autoHide, timeout }`, besides a path.
|
|
272
|
+
- `appId` is a string or one per platform — `{ ios, android, default }` — for an app whose listings already carry
|
|
273
|
+
different ids. The app-link files serve each platform's own id.
|
|
274
|
+
|
|
275
|
+
- ccf2ae2: An app and a lib ship native plugins of their own from a `native/` folder
|
|
276
|
+
|
|
277
|
+
A plugin the runtime has no builtin for sits in `apps/<app>/native/<id>/` (or `libs/<lib>/native/<id>/`), the folder
|
|
278
|
+
named after the id in its `native-plugin.json`. It is listed nowhere: every mobile and desktop target of the app
|
|
279
|
+
ships it, and its manifest says what runs on each platform. A lib's plugins reach only the apps that depend on it; an
|
|
280
|
+
app's own plugin wins an id a lib also uses, and two libs claiming one id stop the build. `akan sync`, `akan doctor`
|
|
281
|
+
and `akan quality scan` accept the folder.
|
|
282
|
+
|
|
283
|
+
The page API is written with `definePlugin` from `akanjs/client/native` (which also exports `defineWebPlugin`,
|
|
284
|
+
`createLiveValue`, `useLiveValue` and `usePluginEvent`), and the desktop part with `defineDesktopPlugin` from the new
|
|
285
|
+
`akanjs/native/desktop`. A `native/` folder is out of the scope of `no-throw-raw-error` (a plugin throws
|
|
286
|
+
`AkanNativeError`) and of `no-web-only-api-outside-webkit`. A plugin a target names by folder in `native.plugins` is now
|
|
287
|
+
granted by its id instead of its path, and ships once when it is also the app's own `native/<id>`. A desktop plugin's
|
|
288
|
+
`ctx.launch.exit(code)` after its setup ran past the launch phase's 3 s quits the app, since its window exists by then.
|
|
289
|
+
|
|
290
|
+
- ccf2ae2: An installed app updates itself: a phone its web bundle over the air, a desktop app the whole app.
|
|
291
|
+
|
|
292
|
+
- `native.updates: { url, publicKey, channel?, readyTimeout? }` in `akan.config.ts`, per target too, the target's
|
|
293
|
+
fields winning. It brings the runtime's `updates` plugin. A channel left unnamed is the backend env the binary is
|
|
294
|
+
built for (`main`, `develop`, `debug`, …), so a `build-desktop` app (`debug` unless `--env` names another) takes
|
|
295
|
+
only the releases published for that env.
|
|
296
|
+
- The CSR frame confirms a release on trial once the first page is on screen, in every native shell (an unconfirmed
|
|
297
|
+
one is rolled back at the next launch). On a phone it also keeps the bundle current by itself: at start and on each
|
|
298
|
+
return to the front it looks for a newer one and downloads it, and it runs from the next cold start. A desktop
|
|
299
|
+
release is the whole app and a relaunch, so the app checks, downloads and applies it on its own schedule. A dev
|
|
300
|
+
build's pages are left alone. `akanjs/client/native` exports `updates`, `markReady` and `useUpdateState`.
|
|
301
|
+
- `akan update-keygen <app> [--platform]` makes the signing key of the app's id once and prints its public half.
|
|
302
|
+
`akan publish-update <app>` builds a release on this machine and signs it: the whole app for this computer's desktop
|
|
303
|
+
OS and CPU (with the app's server when `desktop.server` says so for the target, as for `build-desktop`), or the
|
|
304
|
+
web bundle for `--platform android|ios`. It writes the manifest and its files under `.akan/native/<target>/updates`,
|
|
305
|
+
to upload to `updates.url`, on the channel of its `--env` (`main` unless named). `--channel` names only the manifest
|
|
306
|
+
written, not the channel the release follows, so a pilot group gets a target whose `updates.channel` is the pilot's.
|
|
307
|
+
- The updates folder holds only what is uploaded, and whoever can reach `updates.url` reads it: the updater sends no
|
|
308
|
+
credentials, and a desktop release is the whole app, with the carried server's `private/` (the app's and its libs')
|
|
309
|
+
and its env file.
|
|
310
|
+
- `akan pack-update <app> --platform ios|android [--target] [--env] [--out]` writes an unsigned phone update instead,
|
|
311
|
+
for a signer that keeps the key elsewhere: `files/<sha256>`, `bundle.json` and `manifest.template.json`, the
|
|
312
|
+
manifest with `channel`, `sequence` and `bundle` left for the signer. `--against <store bundle.json>` also checks
|
|
313
|
+
the bundle runs in that store build, writes `compat.json`, and fails when it needs a new binary. The signing
|
|
314
|
+
contract is in the native runtime's architecture notes: fill the three fields, sign exactly the bytes you upload,
|
|
315
|
+
upload `files/` first.
|
|
316
|
+
- `publish-update` refuses a `--channel` outside the names `updates.channel` accepts (lowercase letters, digits, `.`,
|
|
317
|
+
`_`, `-`) before it builds, and numbers a release past the one its channel already has in the updates folder,
|
|
318
|
+
warning when this computer's clock is behind it. Apps compare against the time their own build was made, so keep the
|
|
319
|
+
building and the publishing computers' clocks in step.
|
|
320
|
+
- While a desktop release is on trial, `updates.check()` answers `available: false` for it and `updates.apply()`
|
|
321
|
+
rejects with `NOT_ALLOWED`: applying would replace the app a failed trial goes back to.
|
|
322
|
+
- A release of a desktop app that carries its server is confirmed only once that server answered ready and stayed up
|
|
323
|
+
for 5 s, and only while it is up; its trial clock (`readyTimeout`) starts then, so a server slow on its first run
|
|
324
|
+
(its new files being scanned) costs no rollback. A server that gives up, or is not up within 120 s of the start,
|
|
325
|
+
rolls the release back at once; the release stays downloaded for the next apply, and its third such failure
|
|
326
|
+
excludes it for good.
|
|
327
|
+
- A desktop release's manifest says whether it carries a server, and an installed app refuses one that differs from
|
|
328
|
+
itself before downloading anything (`check()` answers `available: false`, `download()` rejects with
|
|
329
|
+
`NOT_ALLOWED`), since either way its pages' backend would change place. `publish-update` refuses, before it builds,
|
|
330
|
+
a desktop release whose server presence differs from the channel's previous one: publish it on another channel
|
|
331
|
+
(`updates.channel`), or remove that `<channel>.json` from the output folder to start the channel over. Turning
|
|
332
|
+
`native.desktop.server` on or off for an app already installed takes a reinstall.
|
|
333
|
+
- On Windows an app started from its install folder (the installer, the Start menu, Explorer) applies and rolls back
|
|
334
|
+
updates: Windows renames no folder a process works in, so the app moves to its local data folder before the webview
|
|
335
|
+
starts, and the update helper and the app it starts work elsewhere.
|
|
336
|
+
- The update state and the unpacked releases live in the app's local data (`%LOCALAPPDATA%\<app id>\akan-native-updates`
|
|
337
|
+
on Windows), and plugins get that folder as `ctx.appLocalDataDir`.
|
|
338
|
+
- An app installed under another folder name (`/D=`, a renamed `.app`) takes updates; a download older than a
|
|
339
|
+
reinstalled build is dropped; a Windows swap that did not happen (a file stayed locked, the helper never ran) keeps
|
|
340
|
+
the release downloaded for the next apply until its third such failure excludes it. Reinstalling the app clears the
|
|
341
|
+
list of releases the earlier install refused.
|
|
342
|
+
- `publish-update` checks every target before anything builds — its `updates` settings, its native config, the
|
|
343
|
+
signing key on this computer, and whether its channel's previous release carries a server as it would — so a CI
|
|
344
|
+
without the key stops at once and a publish never releases only the first targets. It writes `<channel>.json`
|
|
345
|
+
together with its signature.
|
|
346
|
+
The channels `bundle`, `manifest.template` and `compat` are refused. `akan start` keeps `.akan/native`, where
|
|
347
|
+
releases wait to be uploaded, and the `bin` downloads in `.akan/cache/bin`.
|
|
348
|
+
|
|
349
|
+
- ccf2ae2: An app image carries the server env of the environment it runs and no other.
|
|
350
|
+
|
|
351
|
+
- `env/env.server.ts` imports every environment's file, so `akan build` used to bundle all of them into `server.js`:
|
|
352
|
+
anyone holding the image of one environment could read the keys of every other. The backend build now keeps only
|
|
353
|
+
`env.server.<AKAN_PUBLIC_ENV>.ts`, the environment the generated Dockerfile fixes, and swaps each other
|
|
354
|
+
environment's file (`local`, `testing`, every branch) for exports that refuse to be read. Booting such an image under
|
|
355
|
+
another `AKAN_PUBLIC_ENV` stops with `env/env.server.<name>.ts is not in this build` instead of running on a
|
|
356
|
+
config it does not have. Nothing in `env/` needs to change.
|
|
357
|
+
- An app's generated `server.ts` no longer re-exports `env` from `env.server.testing.ts`, which put the testing env in
|
|
358
|
+
every image. Signal tests read it from the file. A script or test that imported `env` from an app's `server.ts`
|
|
359
|
+
imports `./env/env.server.testing` instead. A lib's `server.ts` still exports it, since each app's
|
|
360
|
+
`env.server.type.ts` spreads it as the lib's defaults.
|
|
361
|
+
- Keys that already shipped in an image stay readable in it. Rotate them.
|
|
362
|
+
- A build for a named `--env` (a mobile or desktop build) writes that env into the Dockerfile too, so the image boots
|
|
363
|
+
the environment its `server.js` carries.
|
|
364
|
+
|
|
365
|
+
**Breaking for an image started under another `AKAN_PUBLIC_ENV`, and for code importing `env` from an app's
|
|
366
|
+
`server.ts`:** the image stops at boot until it runs the environment it was built for, and the import moves to
|
|
367
|
+
`./env/env.server.testing`.
|
|
368
|
+
|
|
369
|
+
- ccf2ae2: A desktop app nobody attends recovers its page, relaunches itself and opens as a kiosk
|
|
370
|
+
|
|
371
|
+
`native.desktop.recovery: "reload"`, for the app or one target, loads a page whose web process ended (a crash, a hang,
|
|
372
|
+
out of memory) again, once per end, instead of showing an error page after the second end, waiting longer after each
|
|
373
|
+
end in a row (1 s, doubling to a minute). It relaunches the app when the webview's browser process ends, where the app
|
|
374
|
+
used to quit: at once the first time, then after 1 s doubling to a minute while each relaunched app dies within a
|
|
375
|
+
minute, and after ten in a row it exits with code 1 instead of restarting for good.
|
|
376
|
+
`native.desktop.window: { fullscreen, skipTaskbar }` opens the main window borderless fullscreen and without a taskbar
|
|
377
|
+
button (Windows, Linux) from its first frame; a plugin's launch phase decides the same per launch with
|
|
378
|
+
`ctx.launch.setWindow`. `app.relaunch()` ends the app and starts it again in a new process on the desktop and Android,
|
|
379
|
+
and reloads the page on the web; one process starts one new app, however many windows or calls asked for it.
|
|
380
|
+
`native.android.autoplay: true` lets media play with sound without a tap first, as it already does on iOS and the
|
|
381
|
+
desktop.
|
|
382
|
+
|
|
383
|
+
An app relaunched by an update on Windows no longer quits the moment it starts when `akan start-desktop` had started
|
|
384
|
+
the one before it.
|
|
385
|
+
|
|
386
|
+
- ccf2ae2: feat(native): Android back belongs to the page only while it has somewhere to go, a back swipe moves the page, and
|
|
387
|
+
memory warnings release hidden pages
|
|
388
|
+
|
|
389
|
+
- **`app.setBackEnabled({ enabled })`** (Android) tells the shell whether a listening page wants back right now. The
|
|
390
|
+
CSR frame reports it after every navigation: enabled while there is history, the keyboard is up, or the index is
|
|
391
|
+
still to come. At the index with nothing under it the shell no longer registers its back callback, so the system
|
|
392
|
+
shows its back-to-home animation and the app stays warm. Before, the page caught that back and called
|
|
393
|
+
`app.exit()`, which finished the task. A new listener starts enabled, so a page that never reports behaves as before.
|
|
394
|
+
- **`app` event `backProgress`** (Android 14+, `OnBackAnimationCallback`) carries `{ phase, progress, swipeEdge }` for
|
|
395
|
+
a back the page will take. The frame drives the current transition's spring with it: a `stack` or `bottomUp` page
|
|
396
|
+
follows the finger, a fade or `scaleOut` page moves half as far, and a cancelled swipe springs back. The commit
|
|
397
|
+
still arrives as `backButton`, and the back animation finishes from wherever the finger let go.
|
|
398
|
+
- **`app-state` event `memoryWarning`** `{ level: "moderate" | "critical" }`: iOS `didReceiveMemoryWarning`, Android
|
|
399
|
+
`onTrimMemory` (the `RUNNING_LOW` / `RUNNING_CRITICAL` levels Android 13 and earlier still send, `BACKGROUND` and
|
|
400
|
+
above) and `onLowMemory`. The frame releases its hidden pages on one; each mounts again when visited.
|
|
401
|
+
- The runtime's plugin host gained `setBackEnabled` and `setBackProgressListener`; the desktop `app` implementation
|
|
402
|
+
answers both as no-ops.
|
|
403
|
+
|
|
404
|
+
Checked on an Android 16 emulator: at a fresh index, back went to the system and the process survived. A left-edge
|
|
405
|
+
swipe on a stack page moved it by the finger (translateX ~76px mid-swipe) and snapped back on cancel; a committed swipe
|
|
406
|
+
went back through `router.back`. `am send-trim-memory RUNNING_CRITICAL` left only the current and previous pages. On an
|
|
407
|
+
iOS 26.5 simulator, Simulate Memory Warning reached the page, and killing the WebContent process reloaded the page onto
|
|
408
|
+
its restored stack.
|
|
409
|
+
|
|
410
|
+
- c45ac6c: perf(server): stop rebuilding per-row and per-request state on the backend hot paths
|
|
411
|
+
|
|
412
|
+
A profile of the backend (`constant` → `signal`) found the database was 3% of a list query and JS
|
|
413
|
+
post-processing was the other 97%. On a 50-row `user` query: 14µs of SQL, then 236µs decoding rows and 221µs
|
|
414
|
+
hydrating them, allocating ~23 000 objects — about 460 per document returned. None of that was the query.
|
|
415
|
+
|
|
416
|
+
The same query now runs in **276–293µs and allocates ~3 100–3 900 objects**: −33% latency, −85% allocation.
|
|
417
|
+
Four changes, each measured independently.
|
|
418
|
+
|
|
419
|
+
**`hydrate()` no longer snapshots rows it does not have to.** Every row of every query was deep-cloned —
|
|
420
|
+
`JSON.parse(JSON.stringify(sanitizeJson(row)))`, 172µs of the 412µs — purely to back `isModified()`. That
|
|
421
|
+
method has two call sites in a real app, both inside `schema.pre("save")` hooks, and a save hook only ever
|
|
422
|
+
runs on a document that came from `create()` or the update path. `hydrate` takes a trailing
|
|
423
|
+
`{ track }` that **defaults to `true`**, so every caller outside the store — including the `Model(data)`
|
|
424
|
+
facade — keeps today's behaviour; only `find` and the projected read behind it opt out.
|
|
425
|
+
|
|
426
|
+
`isModified()` on an untracked document **throws** with a message naming the fix, rather than guessing.
|
|
427
|
+
Both guesses are silently wrong: `false` skips work that was needed, and `true` makes a
|
|
428
|
+
`if (!this.isModified("password"))` hook re-hash an already-hashed password. If you hit it, you are calling
|
|
429
|
+
it on a document that came straight out of a read — move the call into a save hook, or re-load through
|
|
430
|
+
`save()` / `update()`.
|
|
431
|
+
|
|
432
|
+
The snapshot stays a deep clone where it is taken. A lazy one would be wrong: `isModified()` compares against
|
|
433
|
+
state as of hydrate, and document chains mutate `this.status = …` directly, so there is no mutation hook to
|
|
434
|
+
defer to. The six per-document closures (`set`, `save`, `refresh`, `isModified`, `toJSON`, `toObject`) also
|
|
435
|
+
moved to one prototype per store; it extends the model's own document prototype, so chain methods and
|
|
436
|
+
`instanceof` are unaffected and the methods stay non-enumerable exactly as before.
|
|
437
|
+
|
|
438
|
+
**`ConstantField.getProps()` is memoized.** It rebuilt a 27-key object on every call, and the read paths ask
|
|
439
|
+
per field per row — in `decodeDocumentPayload`, again in `crystalize` via `BaseConstant.set()`, again in
|
|
440
|
+
`purify`. It is now built once per field and frozen; no caller mutated the result.
|
|
441
|
+
|
|
442
|
+
**`getDefault()` caches a plan, not a record.** `via()` already memoized it per class, but the database
|
|
443
|
+
adaptor called the standalone function per nested scalar value per row. Thunk defaults
|
|
444
|
+
(`default: () => dayjs()`), array defaults and nested-scalar defaults are still produced per call — only
|
|
445
|
+
values that were already shared before the cache existed are shared now. Caching the finished record would
|
|
446
|
+
have frozen `dayjs()` at boot and aliased one `[]` across every document filled from that model.
|
|
447
|
+
|
|
448
|
+
**Middlewares, guards and slice projections resolve once instead of per request.** `use(env)` takes no
|
|
449
|
+
context, so a middleware instance and its handler are a function of `(class, env)` and now live for the
|
|
450
|
+
process; a rejected setup is evicted so a transient failure does not poison the endpoint. Guards are already
|
|
451
|
+
required to be side-effect free and re-runnable — `revalidateWsRooms` re-runs them outside any request — so
|
|
452
|
+
one instance per class serves every call. `Logging`, which is registered by default, built two template
|
|
453
|
+
strings per request and discarded them unbuilt at the default log level; it now checks
|
|
454
|
+
`Logger.shouldLog("debug")` first (new public method, alongside the existing `isVerbose`).
|
|
455
|
+
|
|
456
|
+
**`WebRouter` stops rebuilding the merged manifest per request.** `#ensureRoute` took a `snapshot()` and then
|
|
457
|
+
merged the runtime manifest over it on every `/*` and `/__rsc` request — four copies of a structure that is
|
|
458
|
+
276KB of JSON for a small app. `RouteClientCache` now exposes a `revision` that bumps on every mutation of
|
|
459
|
+
`merged`, including a delta merge that leaves `generation` alone, and the merge is memoized against it. In
|
|
460
|
+
production the revision never moves after `seed`, so the manifest is built once; in dev a rebuild bumps it and
|
|
461
|
+
the next request pays for one copy. The cached object is still a copy, so an in-flight request keeps consuming
|
|
462
|
+
a stable manifest across an invalidate, exactly as the per-request snapshot guaranteed.
|
|
463
|
+
|
|
464
|
+
**`routeElementComposer` and `routeTreeBuilder` are no longer re-exported from `akanjs/server`.** They pull
|
|
465
|
+
React and the page module graph, and the only consumer is `rscWorker.tsx`, which imports them relatively in its
|
|
466
|
+
own process. Re-exporting them put React into every process touching the barrel — including a
|
|
467
|
+
`SERVER_MODE=batch` replica that renders nothing and any `init({ web: false })` server. The barrel import drops
|
|
468
|
+
from **44.0MiB / 28 338 objects to 38.8MiB / 24 640**. Import them at `akanjs/server/routeTreeBuilder` and
|
|
469
|
+
`akanjs/server/routeElementComposer` if you need them directly.
|
|
470
|
+
|
|
471
|
+
The DI container was measured too and left alone: 11 services, 11 signals, 26 adaptors, 8 database models and
|
|
472
|
+
7 scalars cost 3.7MiB and 27ms, which is 2.5% of process RSS. `diLifecycle.ts` is not where backend memory
|
|
473
|
+
goes — the module graph is.
|
|
474
|
+
|
|
475
|
+
- 69f7178: feat(server): boot a subset of modules with `modules`
|
|
476
|
+
|
|
477
|
+
`new AkanApp("./server", { modules: ["article"] })` mounts only the named modules and the ones they reach; every
|
|
478
|
+
other module stays out of the container, so its service, signal, routes and scheduled jobs do not exist. Omitted
|
|
479
|
+
or empty keeps today's behaviour — every module whose service is enabled. This is what lets one codebase run as
|
|
480
|
+
several small processes, such as a batch worker that carries only its own domain.
|
|
481
|
+
|
|
482
|
+
Dependencies are followed, so you name entry points rather than the whole graph. The closure takes the services
|
|
483
|
+
and signals a module injects, and also its cascade edges: a `removeRef` target and a monomorphic `removeWith`
|
|
484
|
+
owner both fail `CascadeRunner.seal` when absent, so they are boot requirements the inject graph cannot see. A
|
|
485
|
+
polymorphic `refPath` candidate is left out, matching the existing rule that an unmounted one is a mount choice
|
|
486
|
+
rather than a typo. What was mounted is named at boot:
|
|
487
|
+
|
|
488
|
+
```
|
|
489
|
+
[DiLifecycle] INFO Mounting 3 of 12 module(s): article, file, user
|
|
490
|
+
```
|
|
491
|
+
|
|
492
|
+
A name no module registered fails the boot instead of being ignored — a typo would otherwise drop a module
|
|
493
|
+
silently, which is the one failure the option exists to prevent. Selection narrows the enabled set rather than
|
|
494
|
+
replacing it, so it never turns on a module whose service is `enabled: false`.
|
|
495
|
+
|
|
496
|
+
The same selection is available as `AKAN_MODULES=article,file` for a deployment that decides the split, and as
|
|
497
|
+
`{ modules }` on `AkanServer` for an app that starts the server directly. `AkanApp` hands its own option down to
|
|
498
|
+
every child through that variable, since each replica builds its own container.
|
|
499
|
+
|
|
500
|
+
`DiLifecycle`'s constructor now takes `({ env, modules }, ...libs)` instead of `(env, serverMode, ...libs)`. The
|
|
501
|
+
`serverMode` argument was unused — schedule registration takes its own — so it went with the change.
|
|
502
|
+
|
|
503
|
+
- 5f53462: refactor(server): remove the `@CacheMethod` decorator
|
|
504
|
+
|
|
505
|
+
It never hit: it called the model cache as `get(key)` / `set(key, …)` while that cache takes `(topic, key)`, and
|
|
506
|
+
nothing in the framework or its libs used it. Cache an endpoint's answer with `{ cache: <ms> }`, or hold state in a
|
|
507
|
+
service with `memory(...)`.
|
|
508
|
+
|
|
509
|
+
- 5f53462: feat(constant): `cascade: "removeWithAny"` lets a cascade name any model as its owner
|
|
510
|
+
|
|
511
|
+
A polymorphic `removeWith` required its `refPath` to name an `enumOf`, so the one shape an enum cannot express
|
|
512
|
+
had no cascade at all: a child whose owner may be _any_ model in the app — a reaction, a comment, an attachment
|
|
513
|
+
that later models opt into. Listing the candidates in the child's own enum inverts the direction `removeWith`
|
|
514
|
+
exists for, where the owner never learns its children exist.
|
|
515
|
+
|
|
516
|
+
That declaration is now available, opt-in and priced, as a third cascade action:
|
|
517
|
+
|
|
518
|
+
```ts
|
|
519
|
+
parent: field(ID, { refPath: "parentType", cascade: "removeWithAny" }),
|
|
520
|
+
parentType: field(String), // holds the owner's refName
|
|
521
|
+
```
|
|
522
|
+
|
|
523
|
+
The widening is the action, not a flag beside it — `removeWithAny` names the whole decision in one value, so it
|
|
524
|
+
cannot be declared apart from the direction it widens.
|
|
525
|
+
|
|
526
|
+
Every model's removal then sweeps the child by the removed model's own refName. The lookup is cheap — the
|
|
527
|
+
declaration auto-creates the same `{ removedAt, typeKey, fk }` index it always did, so a removal that owns
|
|
528
|
+
nothing is one index probe returning zero rows, not a table scan. The real price is bulk: a query-level removal
|
|
529
|
+
of _any_ model would be a removal whose wildcard children were never looked for, so **one wildcard edge anywhere
|
|
530
|
+
turns every cascade in the app back to one document at a time**. The boot log says so in one `info` line naming
|
|
531
|
+
the edges, because "why does this app remove everything per document" must be answerable without reading a
|
|
532
|
+
model file. An app that declares none keeps the fast path exactly as before.
|
|
533
|
+
|
|
534
|
+
Refused while the class is built: `removeWithAny` on a `refPath` that already names an `enumOf` (the enum
|
|
535
|
+
names the candidates and indexes better — keep one), a `typeKey` that is not a `String` (the sweep matches a
|
|
536
|
+
refName against that column, and a column that cannot hold one silently finds nothing, which is
|
|
537
|
+
indistinguishable from never having declared the cascade), and `removeWithAny` on a field with no `refPath`.
|
|
538
|
+
A wildcard owner is also exempt from the mount check a monomorphic owner fails at boot — it names no module to
|
|
539
|
+
mount.
|
|
540
|
+
|
|
541
|
+
- ccf2ae2: feat(csr): every history entry is a page of its own, the stack keeps three hidden entries, and a dev server can swap
|
|
542
|
+
route modules in place
|
|
543
|
+
|
|
544
|
+
The CSR frame rendered one container per route pattern. A push to the route on screen (`/item?id=1` → `/item?id=2`)
|
|
545
|
+
re-rendered the same page with new arguments: nothing sat under it for a swipe back, and going back handed the
|
|
546
|
+
first entry the second one's state.
|
|
547
|
+
|
|
548
|
+
- **A page container is a history entry.** A push mounts a new page over the old one, which waits under it with its
|
|
549
|
+
state. A replace within one route keeps the entry, so the page updates in place. A `cache` route is still one page
|
|
550
|
+
for the whole session.
|
|
551
|
+
- **The stack keeps the three nearest entries below the one a swipe back reveals,** mounted and hidden (their effects
|
|
552
|
+
stopped), so going back shows each with its state. Older entries are released and mount again on back, with
|
|
553
|
+
their scroll restored.
|
|
554
|
+
- `window.history` entries carry `{ akanEntryId }`, and popstate matches on it (falling back to the href), so two
|
|
555
|
+
entries with one address are told apart.
|
|
556
|
+
- Frame slot targets are per entry too: `<slot>Content-<key>`, with the key in `pathContext.pageKey`. `Navbar`,
|
|
557
|
+
`TopInset`, `TopLeftAction` and `BottomInset` portal into their own page's target, and a page container is
|
|
558
|
+
`#pageContainer-<key>` with `data-path`.
|
|
559
|
+
- **`usePageLocation()`** (`akanjs/webkit`) is this page's own `{ pathname, params, searchParams }`. `st.use.*` follows
|
|
560
|
+
the page on screen, so a page being prepared read the previous page's `?filter=`. `Data.ListContainer` and
|
|
561
|
+
`Data.Dashboard` now seed from their own page.
|
|
562
|
+
- **`replacePages(context)`** (`akanjs/webkit`) rebuilds the route table from new route modules while history,
|
|
563
|
+
mounted pages and stores keep going. It resolves `false` when the set of modules changed, which needs a reload.
|
|
564
|
+
The table itself moved to `CsrRouteTable`, which `bootCsr` now boots.
|
|
565
|
+
- Same-route navigation now replays the enter animation and resets the spring after back, since both follow the
|
|
566
|
+
entry rather than the pathname.
|
|
567
|
+
|
|
568
|
+
- ccf2ae2: feat(csr): the page stack outlives a reload, popstate lands on any entry, and the page under the current one pauses
|
|
569
|
+
in the background
|
|
570
|
+
|
|
571
|
+
- **A reload keeps the stack.** The router writes its entries (`href` + `akanEntryId`) to `sessionStorage` on every
|
|
572
|
+
settled navigation and reads them back at boot when the entry the tab reopened on is the one it was on. That
|
|
573
|
+
covers a reload and a WebView whose content process died and reloaded. Only the current and previous entries
|
|
574
|
+
mount; the rest are dormant until visited. Before this, back after a reload changed the address and left the
|
|
575
|
+
page where it was.
|
|
576
|
+
- **Popstate lands on the entry it names,** not only a neighbour. A long-press back menu or `history.go(-n)` jumps
|
|
577
|
+
there. An entry the stack never saw (from before a reload that kept no stack, or a hash the browser pushed)
|
|
578
|
+
replaces the current one, in place when the route is the same.
|
|
579
|
+
- A popstate whose `hasUAVisualTransition` is set (Safari's own swipe back) skips the frame's back animation, so the
|
|
580
|
+
page does not slide away twice.
|
|
581
|
+
- While the document is hidden (the app in the background), the page under the current one pauses its effects as
|
|
582
|
+
well. `RouteState.isBackgrounded` carries it.
|
|
583
|
+
- On a macOS desktop shell, ⌘[, ⌘← and the mouse back button go back; a text field keeps ⌘←. WebView2 and the browser
|
|
584
|
+
already do this themselves. `desktopPlatform()` is exported from `akanjs/client/native` beside `nativePlatform()`.
|
|
585
|
+
- `useHistory(locations, { idx, dormant })` takes a restored stack, and `setHistoryJump(idx)` moves several entries
|
|
586
|
+
at once.
|
|
587
|
+
|
|
588
|
+
- ccf2ae2: A desktop app can carry the app's own server: with `native: { desktop: { server: true } }` in `akan.config.ts`, or
|
|
589
|
+
`desktop.server` on one target, `akan build-desktop`, `akan start-desktop --release` and `akan publish-update` put the
|
|
590
|
+
backend `akan build` made into the app, so it works on one computer with no backend elsewhere.
|
|
591
|
+
|
|
592
|
+
- The build stages what the backend build wrote into `apps/<app>/.akan/desktop/server` — `main.js`, `server.js`, the
|
|
593
|
+
chunks, `akan.build.json`, `private/`, and any `.node`, `.wasm` or file asset a bundled package brought — without the
|
|
594
|
+
Dockerfile, the RSC worker, the console, `csr/` and `public/`. Its packages install there first
|
|
595
|
+
(`bun install --production --prefer-offline`, without the RSC renderer and the drivers of every database mode but
|
|
596
|
+
`single`), before `akan build` runs, so a computer that cannot reach the registry or find them in Bun's cache stops at
|
|
597
|
+
once. The app needs `single` in `database.modes`; the command says so before it builds.
|
|
598
|
+
- The backend is built for the command's own `--env`, not the workspace's `AKAN_PUBLIC_ENV` (the root `.env` usually
|
|
599
|
+
names `local`), so `server.js` holds that environment's `env.server.<env>.ts` and no other.
|
|
600
|
+
- What it carries is readable in plain text by anyone with the app: `private/` (each lib's too, under
|
|
601
|
+
`private/libs/<lib>`), that `env.server.<env>.ts` and the server env defaults of the libs it uses (each lib's
|
|
602
|
+
`env.server.testing.ts`). Keep deployment secrets out of them. It has no `public/`, and its working
|
|
603
|
+
folder is its data folder: read a runtime file from the app folder (`AKAN_APP_DIR`, else the folder of `Bun.main`),
|
|
604
|
+
never from `process.cwd()`.
|
|
605
|
+
- At launch the shell starts it as a child on its own Bun (`BUN_BE_BUN`, with `.env`, `bunfig.toml` and
|
|
606
|
+
auto-install off) on a loopback port, and hands the page its URL as `PUBLIC_AKAN_SERVER_URL`. The port of the last
|
|
607
|
+
session comes first and a fresh one is picked only when it is taken, so an address registered somewhere usually
|
|
608
|
+
stays valid from one launch to the next. The window waits for it up to 8 s.
|
|
609
|
+
- It runs API only, `operationMode` edge, database mode `single`, SSR, CSR and MCP off, bound to 127.0.0.1 with every
|
|
610
|
+
other Host refused. Any program on the computer can still call that port, so guard its endpoints as a network
|
|
611
|
+
server's. It keeps its SQLite data, files, logs and a per-install JWT secret in the app data folder's
|
|
612
|
+
`server/`, and a debug build, which has the release app's id, in its own `server-debug/`. On Windows that folder is
|
|
613
|
+
`%LOCALAPPDATA%\<app id>\server`, not the Roaming folder a profile copies on every sign-in and an organisation may
|
|
614
|
+
put on a file share, where SQLite's WAL does not work. Bun's transpiler cache goes to
|
|
615
|
+
`<server data>/runtime/transpiler-cache`.
|
|
616
|
+
- It trusts the operating system's CAs (`--use-system-ca`), as the page does, and gets the proxy and CA variables
|
|
617
|
+
(`HTTP(S)_PROXY`, `NO_PROXY`, `NODE_EXTRA_CA_CERTS`, `SSL_CERT_FILE`, `NODE_USE_SYSTEM_CA`) and the desktop session's
|
|
618
|
+
(`DISPLAY`, `WAYLAND_DISPLAY`, the session bus, the audio server, `XDG_*`, and Windows' `ProgramFiles`, `ComSpec`,
|
|
619
|
+
`PATHEXT` and the like), so a `bin` tool reaches what the page reaches and can open the screen, the audio server or a
|
|
620
|
+
shell.
|
|
621
|
+
- `AkanApp` takes `BUN_BE_BUN` off `process.env` at boot and gives it back only to a spawn of its own executable (the
|
|
622
|
+
ops snapshot, which also gets the server's runtime flags), so a `bin` tool built with `bun build --compile` starts as
|
|
623
|
+
itself rather than as the Bun CLI. A child started with `Bun.spawn` and no `env` still gets the environment the
|
|
624
|
+
process started with, so pass `env: process.env`.
|
|
625
|
+
- A crash restarts it on the same port (1 s doubling to 30 s; the fifth in a row gives up with an alert). A server that
|
|
626
|
+
exits before its first ready starts again 250 ms later, on the same port while it is still free, so one that cannot
|
|
627
|
+
boot gives up while the window still waits for it and the window opens at once with the alert. A server
|
|
628
|
+
that cannot start at all (an unreadable `jwt.secret`, a data folder that cannot be made) still hands the page a
|
|
629
|
+
loopback URL and shows the same alert once the window is up, so the page never falls back to the backend its bundle
|
|
630
|
+
was built for. A restart that throws counts as one failure. Its standard error, and its standard output until it is
|
|
631
|
+
ready, also go to `server/runtime/logs/server-output.log`, where a server that fails before it listens leaves its
|
|
632
|
+
reason.
|
|
633
|
+
- Quitting the app stops it within its 1 s shutdown budget, inside the launcher's 1.5 s grace, and a server still
|
|
634
|
+
there after the grace is killed. On macOS and Linux it leads its own process group, and whatever it started ends
|
|
635
|
+
with it: the launcher ends the group when the app quits, and when the app was killed or crashed — even while the
|
|
636
|
+
server was still starting — the server sees its parent gone and ends the group itself, SIGTERM first and SIGKILL a
|
|
637
|
+
second later. A process started `detached` leaves the group. On Windows the job object ends them all with the app.
|
|
638
|
+
- It runs in one process: a `main.ts` or an env asking for a gateway and replicas (`replica`, `solo: false`,
|
|
639
|
+
`AKAN_SOLO=false`, `AKAN_REPLICA`, `AKAN_COMMAND_TYPE=start`) does not boot in the app, and says which.
|
|
640
|
+
- A native plugin follows the server with `ctx.server.state` and `ctx.server.onState` (`starting`, `up`,
|
|
641
|
+
`restarting`, `gaveUp`, `stopped`), and a page with the `app` plugin's `serverState` event, so an app nobody attends
|
|
642
|
+
can act on a server that gave up (`app.relaunch()`, say).
|
|
643
|
+
- `XAUTHORITY`, `PULSE_COOKIE`, `PULSE_RUNTIME_PATH` and `XDG_DATA_DIRS` reach it too. A second launch that hands
|
|
644
|
+
over to the first starts none. The single-instance plugin comes with it.
|
|
645
|
+
- `akan start-desktop` without `--release`, for such a target, follows this checkout's dev server already answering on
|
|
646
|
+
the app's dev port, or starts `akan start` in the same command and opens the app once it serves; it asks for one
|
|
647
|
+
target before it starts one, needs `single` as a release does, and stops at once when that dev server fails to boot
|
|
648
|
+
(a replica's crash loop included) instead of waiting it out. Ctrl+C or closing the app stops the app, then the dev
|
|
649
|
+
server it started, then the local database, one after another, and exits 130.
|
|
650
|
+
- `akan start-desktop --release` of such a target shows the carried server's lines at the level its logger wrote them.
|
|
651
|
+
- `AkanAppConfig.getProductionEnv()` is the env the image runs with; the Dockerfile's `ENV` lines come from it (same
|
|
652
|
+
keys and order, without the two blank lines the template used to leave).
|
|
653
|
+
|
|
654
|
+
- ccf2ae2: A desktop app reads and sets the system volume and mute, and hears when they change
|
|
655
|
+
|
|
656
|
+
The `volume` native plugin (`native.plugins: ["volume"]` in `akan.config.ts`, `volume` from `akanjs/client/native`)
|
|
657
|
+
has `getVolume()`, `setVolume({ level })` and `setMuted({ muted })`, each answering `{ level, muted, settable }`, and
|
|
658
|
+
a `change` event whoever moved the volume. macOS drives the default output device through CoreAudio (an HDMI or
|
|
659
|
+
DisplayPort output takes no volume from the computer and answers `level: null`), Windows the default render endpoint
|
|
660
|
+
the taskbar slider moves, and Linux the default sink of PulseAudio or PipeWire through `pactl`, in any desktop
|
|
661
|
+
language, hearing changes again after the audio server restarts. A phone or the web answers `UNSUPPORTED`, and a Linux
|
|
662
|
+
box without an audio server `NOT_FOUND`.
|
|
663
|
+
|
|
664
|
+
- ccf2ae2: `akan build-desktop --arch arm64|x64` picks the CPU a Windows or Linux desktop app runs on
|
|
665
|
+
|
|
666
|
+
A desktop app is still built on its own OS, now for either of its CPUs: the Rust library is built for that target,
|
|
667
|
+
the executable with `bun build --compile --target=bun-<os>-<arch>`, the carried server installs with
|
|
668
|
+
`bun install --cpu=<arch>` so its addons' prebuilt binaries are that CPU's, and each `bin` entry is that platform's.
|
|
669
|
+
The setup program and the AppImage name the CPU they hold. A macOS app is Apple silicon only: Intel Macs are not a
|
|
670
|
+
target, so `--arch x64` on macOS is refused.
|
|
671
|
+
|
|
672
|
+
- ccf2ae2: A desktop build stops on a server addon that would not load on a user's computer
|
|
673
|
+
|
|
674
|
+
Before it copies the server, a desktop build reads every `.node` file in it by package. A package with no binary for the
|
|
675
|
+
target OS and CPU, a binary that links a library by an absolute path outside the system (a ROS install under `/opt`,
|
|
676
|
+
Homebrew's prefix) or names such an rpath, and a package with a `binding.gyp` that its install never compiled (Bun runs
|
|
677
|
+
no install script of an untrusted package: add it to `trustedDependencies`) are listed and stop the build, instead of
|
|
678
|
+
failing at the first `require` on the user's computer. An addon node-gyp compiled on the build machine only warns.
|
|
679
|
+
|
|
680
|
+
- ccf2ae2: `bin` in `akan.config.ts` puts executables in a desktop app, and the build says what the image installs that the app does not
|
|
681
|
+
|
|
682
|
+
A desktop app ran whatever `ffmpeg` the user's computer had on its PATH, if any, and a Finder-launched app sees only
|
|
683
|
+
`/usr/bin:/bin:/usr/sbin:/sbin`. `bin` names an executable and, per platform (`darwin-arm64`, `win32-x64`, …), where it
|
|
684
|
+
comes from: a download with its `sha256` (an archive takes `file`, the executable inside it) or a path next to the
|
|
685
|
+
declaring `akan.config.ts`. `build-desktop` and `start-desktop` fetch the file for the computer they build on, check it,
|
|
686
|
+
and carry it in the app's `bin/`, which the app puts first on its PATH: the carried server's `spawn("ffmpeg")` runs
|
|
687
|
+
that file, and a native plugin finds it in `ctx.binDir`. A download is kept as `download` plus its URL's extension
|
|
688
|
+
(`.zip`, `.tar.xz`, `.exe`, …), never under a name the URL spells. Bun's own `spawn` without `env` reads the environment the app
|
|
689
|
+
started with, so a plugin passes `env: process.env` to run one by name. A lib's entries reach the apps that depend on
|
|
690
|
+
it, and an app's own entry of the same name wins. macOS builds sign every executable the app carries, in `bin/` and
|
|
691
|
+
in the carried server, found by its Mach-O header rather than its name, so one a package ships without an extension
|
|
692
|
+
passes Developer ID signing and notarization too. The image does not read `bin`: it still installs through `docker`.
|
|
693
|
+
|
|
694
|
+
`build-desktop` of a target that carries its server now warns when the image runs `docker` steps (or the app writes
|
|
695
|
+
its own Dockerfile) and the app carries no `bin`, since the carried server runs none of those steps.
|
|
696
|
+
|
|
697
|
+
- ccf2ae2: `desktop.server: { omit }` carries the desktop app's server without packages only the image needs
|
|
698
|
+
|
|
699
|
+
`native: { desktop: { server: { omit: ["rclnodejs"] } } }` carries the server as `server: true` does, minus the named
|
|
700
|
+
packages and whatever only they pull in: an addon tied to the image's system (a ROS install), or code only a process
|
|
701
|
+
the desktop app never starts loads. The image's `package.json` keeps them, `externalLibs` included; the desktop
|
|
702
|
+
server's `package.json`, its `bun install --production` and the addon check do not see them. A package another
|
|
703
|
+
dependency still installs stops the build, naming the dependents, and the targets that carry one build's server must
|
|
704
|
+
omit the same packages.
|
|
705
|
+
|
|
706
|
+
- ccf2ae2: A desktop app's server reads the files the user picks, with no copy and no path in the page
|
|
707
|
+
|
|
708
|
+
`filePicker.pickFiles({ forServer: true })` (also `pickDirectory` and `saveFile`; `filePicker` is now exported from
|
|
709
|
+
`akanjs/client/native`) copies nothing on the desktop, whatever the file's size: its FileRefs serve the originals,
|
|
710
|
+
and each result carries a grant. The server exchanges the grant with `NativeFile.resolve(grant, "read" | "write" |
|
|
711
|
+
"folder")` from `akanjs/server`, or `NativeFile.resolveIn(folderGrant, relative)`. The carried server asks the shell
|
|
712
|
+
that showed the dialog over its IPC channel, so it reaches what the user picked and nothing else; a debug build
|
|
713
|
+
that carries its server does the same. Behind a dev build without one, `akan start` checks the grant's signature
|
|
714
|
+
instead, in `operationMode` local only. A phone or the web refuses `forServer`.
|
|
715
|
+
|
|
716
|
+
`resolveIn` judges a relative path by where it lands once the links on it are resolved — through its nearest existing
|
|
717
|
+
folder when the file is about to be written — so a link inside the granted folder cannot take it elsewhere, and a
|
|
718
|
+
dangling link is refused. A name that starts with two dots (`..cache/x`) is inside the folder, not above it.
|
|
719
|
+
|
|
720
|
+
- 5f53462: fix(signal): an endpoint's `cache` is looked up after its guards, not ahead of the account
|
|
721
|
+
|
|
722
|
+
The `Cache` middleware sat ahead of every middleware a lib registers, `AccountMiddleware` among them, so on a hit it
|
|
723
|
+
ran the guards before anyone had resolved the caller — a guard reading the account saw none and refused a signed-in
|
|
724
|
+
caller the second time they asked. The lookup is now a step of the call itself: guards, then internal arguments,
|
|
725
|
+
then the cache, then the handler, inside the `timeout` deadline as before.
|
|
726
|
+
|
|
727
|
+
`Cache` is no longer exported from `akanjs/signal` and is no longer registered as a middleware, and
|
|
728
|
+
`SignalContext.checkGuards()` — which existed only for it — is gone. `{ cache: <ms> }` on an endpoint is unchanged.
|
|
729
|
+
|
|
730
|
+
- ccf2ae2: Every builtin native plugin's page API is `akanjs/client/native/<plugin id>`
|
|
731
|
+
|
|
732
|
+
`akanjs/client/native` carries the plugins every page uses, and the rest — `window`, `screen`, `global-shortcut`,
|
|
733
|
+
`keep-awake`, `autostart`, `single-instance`, `screen-orientation`, `clipboard`, `network` and every other builtin — had
|
|
734
|
+
no path an app outside this repository could import, since the runtime's own package ships only inside akanjs. Each is
|
|
735
|
+
now `akanjs/client/native/<id>`, for example `import { appWindow } from "akanjs/client/native/window"`.
|
|
736
|
+
|
|
737
|
+
- 2d8de1c: fix(service): `ServiceModel.getFilterServiceMethods` takes the filter's `FilterInfo` and builds what the runtime builds
|
|
738
|
+
|
|
739
|
+
**Breaking for a direct caller.** The static took a bare query function and guessed the trailing query option by its
|
|
740
|
+
shape, so it disagreed with the filter methods every booted model and service actually carries: `{ sample: 2 }` was
|
|
741
|
+
dropped instead of passed on, `{ sort: null, limit: null }` became a filter argument, omitted arguments were not
|
|
742
|
+
filled, and a filter whose query uses its `q` helper threw because no helper was passed.
|
|
743
|
+
|
|
744
|
+
It now takes the `FilterInfo` — `ServiceModel.getFilterServiceMethods(key, getFilterInfoByKey(Filter, key))` — and
|
|
745
|
+
reads arguments and options exactly as the runtime does, because the runtime now builds each model's and service's
|
|
746
|
+
filter methods through this same static. There is one implementation left. The returned table's type is unchanged.
|
|
747
|
+
|
|
748
|
+
- ccf2ae2: `akan build-desktop --installer true` on Linux adds an AppImage
|
|
749
|
+
|
|
750
|
+
The app folder gains an `AppRun`, a `.desktop` entry naming the app's icon and its `deepLinks.schemes`, and a 256px icon,
|
|
751
|
+
and is packed with `mksquashfs` behind the AppImage type 2 runtime, pinned to a dated release and checked by its
|
|
752
|
+
digest. The AppImage needs only the WebKitGTK 4.1 and GTK 3 the folder already needs, and runs without FUSE through
|
|
753
|
+
`--appimage-extract-and-run`. It runs from a read-only image, so the updates plugin cannot replace it: an app with
|
|
754
|
+
`updates` is warned to publish a new AppImage for each release.
|
|
755
|
+
|
|
756
|
+
- 5f53462: feat(document): a model loader remembers nothing unless it declares `cache`
|
|
757
|
+
|
|
758
|
+
`loader.byField` / `byArrayField` / `byQuery` built their `DataLoader` with the default `cache: true`, and a model's
|
|
759
|
+
loaders live as long as the process — so a key read once was answered from memory forever, however the document
|
|
760
|
+
changed. They now take an option after the default query, `{ cache }`: `false` by default, a number of milliseconds
|
|
761
|
+
to keep each key, or `true` for the life of the process. A failed load is never kept.
|
|
762
|
+
|
|
763
|
+
`DataLoader` itself now defaults to `cache: false` and accepts the same `boolean | number`.
|
|
764
|
+
|
|
765
|
+
- ccf2ae2: A macOS desktop release can be downloaded: Developer ID signing, the hardened runtime, notarization and a dmg
|
|
766
|
+
|
|
767
|
+
`akan build-desktop` signs with a Developer ID when `AKAN_NATIVE_MACOS_IDENTITY` names one in a keychain, or
|
|
768
|
+
`AKAN_NATIVE_MACOS_CERTIFICATE` + `_CERTIFICATE_PASSWORD` a `.p12` (imported into a keychain made for the build and
|
|
769
|
+
deleted after it, for CI). Every Mach-O file is signed inside out with the hardened runtime and a secure timestamp, and
|
|
770
|
+
the executable gets Bun's JIT entitlements plus the camera's or microphone's when a usage text asks for them;
|
|
771
|
+
`native.desktop.entitlements` adds the app's own. With `AKAN_NATIVE_MACOS_NOTARY_KEY` + `_KEY_ID` + `_ISSUER` (an App
|
|
772
|
+
Store Connect API key) or `_NOTARY_PROFILE`, the app is notarized, stapled and checked with `spctl`.
|
|
773
|
+
`--installer true` on macOS adds a dmg with an Applications link, signed, notarized and stapled the same way.
|
|
774
|
+
`publish-update` signs a macOS release with the same identity, since the updater checks the installed app's signature.
|
|
775
|
+
A release that is not signed with a Developer ID, or not notarized, warns that Gatekeeper blocks a downloaded copy.
|
|
776
|
+
|
|
777
|
+
- 69f7178: feat(mcp)!: expose every endpoint its guards admit, and mount `/mcp` by default
|
|
778
|
+
|
|
779
|
+
MCP exposure was opt-in per endpoint, per slice and per CRUD verb. It is now derived from the guards an endpoint
|
|
780
|
+
already declares, and `/mcp` is mounted unless `AKAN_MCP=false` says otherwise.
|
|
781
|
+
|
|
782
|
+
The old model asked the same question twice. `guards` is the authorization decision, and `McpDispatcher`
|
|
783
|
+
re-evaluates the account-scoped ones on every `tools/list` — so a second per-endpoint switch said nothing the
|
|
784
|
+
guards did not, while guaranteeing that every endpoint added later was invisible to agents until somebody
|
|
785
|
+
remembered to flag it. The framework's own OpenAPI document has no per-endpoint opt-in either: one env flag
|
|
786
|
+
publishes the whole HTTP surface, which is the same enumeration.
|
|
787
|
+
|
|
788
|
+
**The rule, applied globally with no way to configure it per endpoint or per app:**
|
|
789
|
+
|
|
790
|
+
- a real guard publishes
|
|
791
|
+
- **no `guards` at all is refused** — nobody decided who may reach it, and a catalogue entry is where that omission
|
|
792
|
+
would stop being invisible
|
|
793
|
+
- **a mutation whose only guard is `Public` is refused**, unchanged: `[Public]` is having none, spelled out
|
|
794
|
+
- an explicit `guards: [Public]` on a _read_ publishes. It is a decision someone wrote, the data is already served
|
|
795
|
+
anonymously over HTTP and in the OpenAPI document, and refusing it would break a public docs or search endpoint
|
|
796
|
+
while hiding nothing
|
|
797
|
+
|
|
798
|
+
Every structural refusal is unchanged: `pubsub`/`message`, an `Any` or `Upload` return, a file upload, a required
|
|
799
|
+
`Any` argument, and the three a prompt's flat string map cannot carry.
|
|
800
|
+
|
|
801
|
+
**Removed:** `mcp` on a signal option, on a slice option and on the signal's CRUD verb map — `McpOption` and
|
|
802
|
+
`McpSliceOption` are gone, and `mcp: { expose: true }` no longer typechecks. Delete it; nothing replaces it. The
|
|
803
|
+
`readOnly` / `destructive` / `idempotent` hints are now derived from the endpoint type and key rather than
|
|
804
|
+
overridable, which costs nothing a client trusts. `resource` is gone the same way: the generated reads are
|
|
805
|
+
addressable, a custom endpoint is not, and neither was ever configurable in practice.
|
|
806
|
+
|
|
807
|
+
`McpDocument.unguarded` is gone, subsumed by the refusal that now covers it. The `akan.mcp.missing-description`
|
|
808
|
+
and `akan.mcp.unguarded-exposure` quality-scan rules are gone with `McpScanner`: both found the exposure by
|
|
809
|
+
matching an `mcp: { expose: true }` literal in source, which no longer exists. The boot log already answered both
|
|
810
|
+
from the resolved catalogue, and it is now the only place that can.
|
|
811
|
+
|
|
812
|
+
**`GuardCls.scope` is required.** Unmarked meant `resource`, which is never evaluated for a listing — so with
|
|
813
|
+
exposure following the guards, one forgotten marker would list its endpoint's name and argument schema to every
|
|
814
|
+
caller and refuse only the call. There is no safe guess, so the author states it: `"account"` for a verdict that
|
|
815
|
+
reads the caller and nothing about the call, `"resource"` for one that needs the call's arguments.
|
|
816
|
+
|
|
817
|
+
**Upgrading:** delete every `mcp:` option from your signal files, and add `static scope: GuardScope = "account" |
|
|
818
|
+
"resource"` to every guard class. Then read the boot log — `MCP catalogue: tools=…` followed by one line per
|
|
819
|
+
refusal — because with no opt-in to be missing, that log is the only explanation for a tool that is not there.
|
|
820
|
+
An endpoint you want an agent to reach needs real guards, which it needed anyway.
|
|
821
|
+
|
|
822
|
+
**Settings moved off `new AkanApp(...)`.** `AkanAppOptions.mcp` is gone; the server settings live on the option
|
|
823
|
+
chain in `lib/option.ts` — `option.setMcp({ enabled, readOnly, path, version, instructions, allowedOrigins,
|
|
824
|
+
pageSize, language, auth })`. The old field is a type error on upgrade, which is the signal to move it.
|
|
825
|
+
|
|
826
|
+
- 5f53462: fix(service): `memory()` is scoped to its owner and does what its options say
|
|
827
|
+
|
|
828
|
+
- A non-`Map` memory was keyed by its property name alone, so two services that each declared `token` shared one
|
|
829
|
+
value. It is now stored under the owner's refName, like a `Map` memory already was. Values written under the old
|
|
830
|
+
key are not read back.
|
|
831
|
+
- `get` / `set` were type-checked and then ignored at runtime. They now run: `get` turns the stored value (a `Map`'s
|
|
832
|
+
entry value) into what the code reads, and `set` is its inverse.
|
|
833
|
+
- A declared `default` is what a missing value reads as on Redis too; Redis answers a missing key with `null`, which
|
|
834
|
+
used to skip it.
|
|
835
|
+
- `local: true` on a `Map` gives a `Map`, not `null`.
|
|
836
|
+
- The declaration's `expireAt` was one fixed time computed when the class loaded. It is replaced by `ttl` in
|
|
837
|
+
milliseconds, applied to each write that names no `expireAt` of its own.
|
|
838
|
+
- `RedisCache` expires a hash field on its own. `PEXPIREAT` on the whole hash expired every entry with the last one
|
|
839
|
+
written; each field's expiry now lives in a sorted set beside the hash (portable to Redis before 7.4), expired
|
|
840
|
+
fields read as missing, and they are dropped on every write and every listing.
|
|
841
|
+
|
|
842
|
+
- 3ebd8e1: feat(document): `updateById` / `removeById` on the model facade
|
|
843
|
+
|
|
844
|
+
The facade could already read one document by id (`findById`, `pickById`) but writing one meant spelling the id back
|
|
845
|
+
out as a query: `Model.updateOne({ id }, { … })`. It now carries the id-scoped pair directly.
|
|
846
|
+
|
|
847
|
+
```ts
|
|
848
|
+
await this.Order.updateById(orderId, { status: "archived" });
|
|
849
|
+
await this.Order.updateById(
|
|
850
|
+
orderId,
|
|
851
|
+
{ status: "archived" },
|
|
852
|
+
{ upsert: true }
|
|
853
|
+
);
|
|
854
|
+
await this.Order.removeById(orderId);
|
|
855
|
+
```
|
|
856
|
+
|
|
857
|
+
**These are the query-level writes narrowed to one id, not the document path.** They compile to the same single
|
|
858
|
+
atomic UPDATE as `updateOne` / `removeOne` — `removedAt IS NULL` ANDed in, counts returned rather than a document,
|
|
859
|
+
and **no hooks**, so no `_pre`/`_postUpdate`, no `_postRemove`, and no cascade. A model whose removal carries a side
|
|
860
|
+
effect or a `removeRef` / `removeWith` edge still goes through the service's `remove<Model>(id)`.
|
|
861
|
+
|
|
862
|
+
`updateById` takes the same trailing options as `updateOne`, so `{ upsert: true }` inserts with that id when nothing
|
|
863
|
+
matches. An `undefined` id is rejected by the query compiler rather than widening the write.
|
|
864
|
+
|
|
865
|
+
- 326da07: fix(document): give a Model's `insight<Query>` a real return type, and declare `db.<Model>Insight`
|
|
866
|
+
|
|
867
|
+
Declaring a filter query generates `count<Query>` and `insight<Query>` on both the Model and the Service. On the
|
|
868
|
+
Service the insight was typed; on the Model it came back as `unknown`, because `into()` passed `unknown` where
|
|
869
|
+
`QueryMethodPart` expects the insight type — so `await this.adminModel.insightByAccountId(id)` had no fields and
|
|
870
|
+
any use of the result needed a cast.
|
|
871
|
+
|
|
872
|
+
`into()` now threads the insight through from the constant model it already receives, typed as the document shape
|
|
873
|
+
because `insight()` accumulates into a plain record rather than a hydrated document.
|
|
874
|
+
|
|
875
|
+
The generated `lib/db.ts` also declares an insight document class per model and exports the type, mirroring what it
|
|
876
|
+
already did for `Input`:
|
|
877
|
+
|
|
878
|
+
```ts
|
|
879
|
+
class AdminInsight extends by(cnst.AdminInsight) {}
|
|
880
|
+
export type { AdminInsight };
|
|
881
|
+
```
|
|
882
|
+
|
|
883
|
+
`DatabaseRegistry.buildModel` takes that class instead of the constant insight, so `db.<Model>Insight` annotates
|
|
884
|
+
the result on both sides:
|
|
885
|
+
|
|
886
|
+
```ts
|
|
887
|
+
const insight: db.AdminInsight = await this.adminModel.insightByAccountId(
|
|
888
|
+
accountId
|
|
889
|
+
);
|
|
890
|
+
const count: number = await this.countByAccountId(accountId);
|
|
891
|
+
```
|
|
892
|
+
|
|
893
|
+
Run `akan sync <app-or-lib>` to regenerate `lib/db.ts`. A hand-written `DatabaseRegistry.buildModel` call — test
|
|
894
|
+
fixtures, mostly — now needs a `by()`-wrapped insight; wrap it after the `ConstantRegistry.buildModel` call, since
|
|
895
|
+
`by()` resolves the refName from the registry.
|
|
896
|
+
|
|
897
|
+
- ccf2ae2: A native platform section takes its own `indexPath`
|
|
898
|
+
|
|
899
|
+
`ios.indexPath`, `android.indexPath` and `desktop.indexPath` win over the section's (or the target's) `indexPath` on
|
|
900
|
+
that platform, so one target opens the phones on `/mobile` and the desktop app on `/` with no `--target`. It moves
|
|
901
|
+
both the dev build's first page and the `indexPath` a release bundle carries.
|
|
902
|
+
|
|
903
|
+
- ccf2ae2: Mobile apps build and run on `@akanjs/native` instead of Capacitor.
|
|
904
|
+
|
|
905
|
+
The runtime ships inside `akanjs` (vendored, not published on its own), so an app installs nothing extra for it.
|
|
906
|
+
It generates each target's native projects under `.akan/native/<target>/build/<platform>` and loads the web
|
|
907
|
+
root it assembles in `.akan/native/<target>/web` from the production CSR build — nothing native is committed.
|
|
908
|
+
|
|
909
|
+
- **Configuration (`akan.config.ts`).** A `native` section, and each of its `targets`, takes `appName`, `appId`,
|
|
910
|
+
`fileName`, `version`, `buildNum`, `basePath`, `indexPath`, `icon`, `splash`, `permissions`, `plugins` (builtin
|
|
911
|
+
ids such as `iap`), `deepLinks`, `ios.{infoPlist, entitlements, files}` and
|
|
912
|
+
`android.{manifest, application, activity, googleServices, files}`; a target overrides the section field by field.
|
|
913
|
+
`files` maps where a file lands (a path in the iOS app bundle, `res/<type>/<file>` or `assets/<path>` on Android)
|
|
914
|
+
to its source. A Capacitor-era key fails, naming the keys the section takes.
|
|
915
|
+
- **Plugins declare, they do not edit projects.** `AkanPlugin.native` names the permission it serves and what the
|
|
916
|
+
target then needs: native plugins, usage texts, plist and entitlement entries, Android permissions and features.
|
|
917
|
+
It replaces `capacitor.configureNative`, `editIosAppDelegate` and `runtimePackages`. A release build names each
|
|
918
|
+
plugin's default permissions as its capabilities.
|
|
919
|
+
- **Commands.** `build-`, `start-` and `release-ios|android` go through the runtime. `start-*` follows `akan start`
|
|
920
|
+
(it refuses when no dev server answers), runs one target at a time and takes `--device` (and `--team`/`-T` on
|
|
921
|
+
iOS); `--release` runs a release build of its own bundle. `release-ios` takes `--team` and `--ad-hoc`;
|
|
922
|
+
`release-android` builds an `.aab` by default (`--assemble-type apk` for an `.apk`), signed from
|
|
923
|
+
`MYAPP_RELEASE_STORE_FILE`, `MYAPP_RELEASE_STORE_PASSWORD` and `MYAPP_RELEASE_KEY_ALIAS` (plus
|
|
924
|
+
`MYAPP_RELEASE_KEY_PASSWORD` when the key has its own), which it checks before anything builds. `build-*` take
|
|
925
|
+
`--debug`, and
|
|
926
|
+
`akan doctor --ios` adds the native toolchain checks.
|
|
927
|
+
- **Pages.** `akanjs/client/native` wraps the native plugins a page uses, and `isNativeApp()` is true only inside
|
|
928
|
+
a shell. `Device` reads the runtime's device plugin and safe-area variables, `storage` the shell's preferences,
|
|
929
|
+
and the new `secretStorage` keeps the JWT and refresh tokens in the OS credential store (cleared on the first
|
|
930
|
+
launch after a reinstall). `useCamera` is one hook — the native sheet asks camera or library and answers an
|
|
931
|
+
upright JPEG data URL — and `useGeoLocation` answers the flat native position. A native release bundle opened at
|
|
932
|
+
`/` starts on its target's home, and a target opened in a browser is a web device.
|
|
933
|
+
|
|
934
|
+
**Breaking, with a migration path.**
|
|
935
|
+
|
|
936
|
+
- Delete the app's `ios/`, `android/`, `mobile/` folders and `capacitor.config.*`: `akan sync`, `akan doctor` and
|
|
937
|
+
`akan quality scan` now refuse them in an app root and say why.
|
|
938
|
+
- Move Capacitor settings to the keys above; `google-services.json` goes to `native.android.googleServices`, and
|
|
939
|
+
`GoogleService-Info.plist` is no longer needed.
|
|
940
|
+
- Removed: `-g/--regenerate`, `--open`, `--allow-provisioning-updates`, `configure-app`, `codepush`,
|
|
941
|
+
`release-source`, `capacitor.base.config`, `useContact`, `useCodepush`, the native path of `useSpeech`, and every
|
|
942
|
+
`@capacitor/*`, `@capacitor-community/*`, `@capgo/*`, `capacitor-plugin-safe-area`, `cordova-plugin-purchase`
|
|
943
|
+
and `@trapezedev/project` dependency — `akanjs` no longer lists them as peers.
|
|
944
|
+
- `baseSt` drops `deviceToken`; the notification store owns it.
|
|
945
|
+
|
|
946
|
+
- ccf2ae2: `native` in `akan.config.ts` replaces `mobile`, with each platform's settings in a section of its own
|
|
947
|
+
|
|
948
|
+
**Breaking:** `mobile` is gone with no fallback; a config that still has it, or a key that moved, stops and names the
|
|
949
|
+
new place. `native` and each of `native.targets` take the same fields, a target merging objects key by key and
|
|
950
|
+
replacing lists and every other value, and without `targets` the app has one target, `default`. The next `akan start`
|
|
951
|
+
removes the old `.akan/mobile`.
|
|
952
|
+
|
|
953
|
+
The agent guide `akan agent install` writes now covers native apps: the `native` section, the `start-*` / `build-*`
|
|
954
|
+
commands and updates, with the full contract in the `runtimeRule` guideline.
|
|
955
|
+
|
|
956
|
+
| Before | Now |
|
|
957
|
+
| ---------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
|
|
958
|
+
| `mobile` | `native` |
|
|
959
|
+
| `mobile.native.X`, `targets.<t>.native.X` (`plugins`, `ios`, `android`, `desktop`) | `native.X`, `native.targets.<t>.X` |
|
|
960
|
+
| `native.push.android` | `android.push` |
|
|
961
|
+
| `native.privacy` | `ios.privacy` |
|
|
962
|
+
| `assets.icon`, `assets.splash` | `icon`, `splash` |
|
|
963
|
+
| `files["ios/<path>"]` | `ios.files["<path>"]` |
|
|
964
|
+
| `files["android/res/…"]`, `files["android/assets/…"]` | `android.files["res/…"]`, `android.files["assets/…"]` |
|
|
965
|
+
| `deepLinks.ios.teamId` | `ios.teamId` |
|
|
966
|
+
| `deepLinks.android.sha256CertFingerprints` | `android.sha256CertFingerprints` |
|
|
967
|
+
| `indexPath` on a target only | on `native` too, inherited by every target |
|
|
968
|
+
| root and target `plugins` joined, Android XML lists appended | the target's list replaces |
|
|
969
|
+
| `.akan/mobile/<target>/native/<platform>` | `.akan/native/<target>/build/<platform>` |
|
|
970
|
+
| `.akan/mobile/<target>/{dev,updates,web,bin}` | `.akan/native/<target>/{dev,updates,web,bin}` |
|
|
971
|
+
| `AkanMobileConfig`, `AkanMobileTargetConfig` | `AkanNativeAppConfig` (resolved: `AkanNativeAppResult`), `AkanNativeTarget`; `AkanNativeSettings` is what `native` and a target share |
|
|
972
|
+
| `AkanMobileUpdatesConfig`, `AkanMobileAppId`, `AkanMobileTargetDeepLinks` | `AkanNativeUpdatesConfig`, `AkanNativeAppId`, `AkanNativeDeepLinks` |
|
|
973
|
+
| `AkanMobileNativeConfig` | `AkanNativeIosConfig`, `AkanNativeAndroidConfig`, `AkanNativeDesktopConfig` |
|
|
974
|
+
| `AkanMobileTargetAssets`, `AkanMobileTargetFiles` | removed: `icon`, `splash` and each platform's `files` |
|
|
975
|
+
| `MobilePermission`, `MobileEnv`, `AppConfigResult.mobile` | `NativePermission`, `NativeEnv`, `AppConfigResult.native` |
|
|
976
|
+
|
|
977
|
+
- ccf2ae2: feat(csr): only the current page publishes to the in-page agent, and a page can bind work to being looked at
|
|
978
|
+
|
|
979
|
+
A CSR page kept under the current one for a swipe back is mounted and live, so its `st.tool`s, resources, guides and
|
|
980
|
+
`st.use` keys used to sit in the agent's surface beside the current page's. The agent was offered a lever the user
|
|
981
|
+
could not see, and two pages declaring one name clashed.
|
|
982
|
+
|
|
983
|
+
- **`<AgentActivity active>`** (`use-agentic`) publishes what is registered below it only while `active`.
|
|
984
|
+
Registrations stay in place, so turning it back on re-publishes without a remount. A parked registration shadows
|
|
985
|
+
nothing and never clashes with a published one. Nested activities close with their parent. A registry kept outside
|
|
986
|
+
the surface reads the same gate through `useAgentGate()`. Under it are `AgenticSurface.gate(active, parent?)` and
|
|
987
|
+
`AgenticSurface.gated(gate)`, and the registration methods take the gate as an optional trailing argument.
|
|
988
|
+
- Every CSR page container renders one, open only for the current page. The store's live keys follow it, so
|
|
989
|
+
`readState` answers for the screen the user sees.
|
|
990
|
+
- The agent's screen targeting (`highlight`, a zone's `readScreen`, the cursor) skips anything under an `inert`,
|
|
991
|
+
`aria-hidden` or `hidden` ancestor, not only elements that carry one themselves.
|
|
992
|
+
- **`usePageFocusEffect(effect, deps)`** (`akanjs/webkit`) runs `effect` while the user is on the page and cleans it up
|
|
993
|
+
when they leave: after the entrance settles, and as soon as the page starts to go. It is for a camera, a poll or a
|
|
994
|
+
key binding that a live page under the current one must not keep running. **`usePageActivity()`** answers
|
|
995
|
+
`"current" | "prev" | "pending" | "hidden"`, and `current` outside a CSR stack.
|
|
996
|
+
|
|
997
|
+
- ccf2ae2: Push notifications go to each device token over the service that issued it — APNs or FCM — and a tap opens its
|
|
998
|
+
page from the app's first frame.
|
|
999
|
+
|
|
1000
|
+
- `akanjs/client/native` exports `push`: permission, `register()` answering `{ token, provider, platform }`,
|
|
1001
|
+
`unregister`, foreground presentation, and the `token`, `received` and `action` events. A tap that launched the
|
|
1002
|
+
app is held until the first listener, and `akanjs` routes it from boot: an `action` whose `data.url` is an app
|
|
1003
|
+
path opens it, and a foreground notification shows as a banner.
|
|
1004
|
+
- `akanjs/client/capacitor` is gone.
|
|
1005
|
+
|
|
1006
|
+
Libraries synced into a workspace:
|
|
1007
|
+
|
|
1008
|
+
- `libs/util`: `usePushNotification` takes the native `push` plugin in a shell and Firebase on the web, and
|
|
1009
|
+
answers a `PushToken` of `{ token, platform, provider, deviceId }`, where `deviceId` is a random id kept per
|
|
1010
|
+
installation (`getPushDeviceId()`). `PushNotificationServer` reads `pushNoti.firebase` and `pushNoti.apns`
|
|
1011
|
+
(`teamId`, `keyId`, the `.p8` key's `privateKey`, `bundleId`, optional `environment`) and sends APNs itself
|
|
1012
|
+
over HTTP/2 with an ES256 provider token; a token APNs or FCM reports gone is dropped. Topics are gone.
|
|
1013
|
+
- `libs/shared`: `notiInfo.deviceTokens` holds one `DeviceToken` per installation (token, provider, platform,
|
|
1014
|
+
`deviceId`, `updatedAt`), replaced on re-registration and removed on sign-out (`signoutUser(pushDeviceId)`).
|
|
1015
|
+
An `all` notification fans out to active users in pages of 500 and honours each one's notification settings,
|
|
1016
|
+
which the old topic send did not.
|
|
1017
|
+
|
|
1018
|
+
**Breaking.** Stored plain-string tokens are dropped on read and each device registers again on its next visit.
|
|
1019
|
+
`addNotiDeviceTokenOfSelf` takes a `DeviceToken`, `subscribeToMegaphone` is gone, and a server sending to iOS
|
|
1020
|
+
needs `pushNoti.apns`.
|
|
1021
|
+
|
|
1022
|
+
- ccf2ae2: chore(deps): React 19.3 — `react`, `react-dom` and `react-server-dom-webpack` 19.3.0, `react-refresh` 0.19,
|
|
1023
|
+
`scheduler` 0.28
|
|
1024
|
+
|
|
1025
|
+
19.3.0 ships the `pingSuspendedRoot` fix that `patches/react-dom@19.2.7.patch` carried, so the patch and the workspace
|
|
1026
|
+
template's copy are gone. The vendor shims name 19.3's new exports: `ViewTransition` and `addTransitionType` from
|
|
1027
|
+
`react`, `browser` from `react-dom`, and the whole `react-refresh/runtime` surface.
|
|
1028
|
+
|
|
1029
|
+
**For an existing workspace:** `akanjs` depends on `react` and `react-dom` 19.3.0 exactly, so move any workspace pin to
|
|
1030
|
+
19.3.0 in the same install — two React copies break every hook. Drop `patchedDependencies["react-dom@19.2.7"]` from the
|
|
1031
|
+
root `package.json` and delete `patches/react-dom@19.2.7.patch`.
|
|
1032
|
+
|
|
1033
|
+
- 326da07: feat(document): query-level `remove`/`update` per filter, and rename `deleteMany` to `removeMany`
|
|
1034
|
+
|
|
1035
|
+
The model facade's `deleteMany(query)` and the store's `deleteManyByQuery(query)` never deleted anything: both
|
|
1036
|
+
stamp `removedAt` in one atomic UPDATE, exactly like `remove(id)` does. The framework has no hard delete for a
|
|
1037
|
+
model table at all — `DELETE FROM` appears only against the cache, `_akan_meta`, and the search mirror. They are
|
|
1038
|
+
now `removeMany` and `removeManyByQuery`, so the name matches the write, and `delete` stays free to mean a real
|
|
1039
|
+
`DELETE` if one is ever added. Rename the call sites; the behaviour is unchanged.
|
|
1040
|
+
|
|
1041
|
+
Filters now generate four query-level writes alongside the ten readers, on both the service and the model:
|
|
1042
|
+
|
|
1043
|
+
```ts
|
|
1044
|
+
await this.removeInCategory("news"); // every match
|
|
1045
|
+
await this.removeOneInCategory("news"); // the newest match (createdAt desc)
|
|
1046
|
+
await this.updateInCategory("news").set({ status: "archived" }); // every match
|
|
1047
|
+
await this.updateOneInCategory("news").set({ status: "archived" });
|
|
1048
|
+
```
|
|
1049
|
+
|
|
1050
|
+
**The update pair is a chain.** The patch cannot trail the filter args — those may be optional, and no tuple type
|
|
1051
|
+
puts a required element after an optional one — and leading it reads backwards. So it lands on a terminal `.set()`,
|
|
1052
|
+
which mirrors the `UPDATE … SET …` the call compiles to. Building the chain runs no query; only `.set()` does.
|
|
1053
|
+
|
|
1054
|
+
These are query-level writes: one atomic UPDATE that **fires no hooks**, so no `_pre`/`_postRemove` and no cascade
|
|
1055
|
+
run. Reach for them on a model that carries no removal side effect, and remove documents one at a time otherwise.
|
|
1056
|
+
`removeOne`/`updateOne` hit the **newest** match: their subquery is ordered `createdAt` descending, the caller cannot
|
|
1057
|
+
change that, and the result carries counts rather than an id. They are for "there is at most one of these", not for
|
|
1058
|
+
claiming the next item off a queue.
|
|
1059
|
+
|
|
1060
|
+
Because filter methods are assigned after CRUD, a filter keyed after its own model would have silently replaced
|
|
1061
|
+
the single-document `remove<Model>`/`update<Model>` with a hookless query-level one. That now throws while the
|
|
1062
|
+
service is being resolved instead. Services also gained `__removeMany`, `__removeOne`, `__updateMany`, and
|
|
1063
|
+
`__updateOne`, and the store gained `removeOneByQuery`.
|
|
1064
|
+
|
|
1065
|
+
On the **model facade**, `countDocuments(query)` is now `count(query)`; the old name still works and is marked
|
|
1066
|
+
`@deprecated`. Its writes keep `Many`/`One` spelled out — `updateOne` / `updateMany` / `removeOne` / `removeMany` —
|
|
1067
|
+
rather than following the short `find`/`findOne` pair: a bare `Model.update`/`Model.remove` would read like the
|
|
1068
|
+
document-path `update(id)` and `doc.remove()` while quietly hitting every match.
|
|
1069
|
+
|
|
1070
|
+
- 5f53462: feat(route): a route's head is JSX only, and analytics is the app's own component
|
|
1071
|
+
|
|
1072
|
+
**`.metadata()` is gone, along with the `metadata` / `generateMetadata` exports and the `AkanMetadata` type.** A route
|
|
1073
|
+
had two ways to say the same thing — an object of `title` / `description` / `openGraph` / `twitter` / `alternates`
|
|
1074
|
+
that the server translated into tags, and `.head()` taking the tags themselves — and could use only one of them per
|
|
1075
|
+
module. Every app in the workspace already wrote `.head(<>…</>)`. Rewrite an object as the tags it produced:
|
|
1076
|
+
|
|
1077
|
+
```tsx
|
|
1078
|
+
.head(({ projectId }) => (
|
|
1079
|
+
<>
|
|
1080
|
+
<title>{`Project ${projectId}`}</title>
|
|
1081
|
+
<meta name="description" content="Project workspace" />
|
|
1082
|
+
<meta property="og:image" content="/og/project.png" />
|
|
1083
|
+
<link rel="canonical" href="https://example.com/project" />
|
|
1084
|
+
</>
|
|
1085
|
+
))
|
|
1086
|
+
```
|
|
1087
|
+
|
|
1088
|
+
A legacy route that still exports `metadata` or `generateMetadata` now fails the load as an unsupported export,
|
|
1089
|
+
the same way any other stray export does. The hreflang alternates Akan adds per locale are unchanged; the only thing
|
|
1090
|
+
that could suppress them was an `alternates.languages` object, so leave them out of the JSX.
|
|
1091
|
+
|
|
1092
|
+
**`.gaTrackingId()` and the `gaTrackingId` export / `System.Provider` prop are gone.** Which tags load, in which
|
|
1093
|
+
environment and behind which consent banner are the app's decisions, and the framework's built-in `Gtag` made none
|
|
1094
|
+
of them. Render a `"use client"` component that loads gtag.js from the root layout's `.render()` instead — the
|
|
1095
|
+
routing guide has one. GA4's Enhanced measurement already counts the router's `history.pushState` navigations, so
|
|
1096
|
+
the component only loads the tag.
|
|
1097
|
+
|
|
1098
|
+
The head-snapshot path used by the experimental partial commit (`AKAN_PUBLIC_RSC_PARTIAL_COMMIT=1` with
|
|
1099
|
+
`rscPatchHeadSafe`) was fed only by the metadata object, so it now always falls back to a full navigation.
|
|
1100
|
+
|
|
1101
|
+
- ccf2ae2: `AKAN_LISTEN_HOST` binds the server to one address instead of every interface, and `AKAN_ALLOWED_HOSTS`
|
|
1102
|
+
(comma-separated `host:port`) refuses any request, socket upgrade and preflight included, whose `Host` header it
|
|
1103
|
+
does not list, with 403. A page on a name that re-resolves to 127.0.0.1 (DNS rebinding) is same-origin with a
|
|
1104
|
+
loopback server, and `CrossSiteGuard` reads `Origin` equal to `Host` as same-site; only the `Host` header, never
|
|
1105
|
+
`x-forwarded-host`, still names that page's domain. Both apply to a solo server and to the gateway; a server behind
|
|
1106
|
+
the gateway skips the check, since the Host it sees is the gateway's hop.
|
|
1107
|
+
|
|
1108
|
+
Both are meant for a server only its own computer calls, as the server a desktop app carries is. A server that renders
|
|
1109
|
+
pages calls itself at `localhost:<PORT>` (SSR and its RSC worker), so a list that names only the public host refuses
|
|
1110
|
+
those calls with 403: put `localhost:<PORT>` in `AKAN_ALLOWED_HOSTS` too, and keep `AKAN_LISTEN_HOST` on an address
|
|
1111
|
+
that `localhost` reaches.
|
|
1112
|
+
|
|
1113
|
+
A CSR bundle reads `PUBLIC_AKAN_SERVER_URL` from the native shell's launch env (`__AKAN_NATIVE__.env`) ahead of the
|
|
1114
|
+
built-in `AKAN_PUBLIC_SERVER_URL`, for a server whose port is known only when the app starts.
|
|
1115
|
+
|
|
1116
|
+
- 69f7178: feat(signal): serve any app's signals to agents as an MCP server on `POST /mcp`
|
|
1117
|
+
|
|
1118
|
+
An endpoint already declares everything an agent tool needs — a name, typed arguments, a return model, guards, and
|
|
1119
|
+
a dictionary entry saying what it is for in two languages. What was missing was the wire. Every `*.signal.ts` can
|
|
1120
|
+
now be published as Model Context Protocol tools, resources, and prompts, with no per-endpoint adapter code.
|
|
1121
|
+
|
|
1122
|
+
Turn it on in an app's `main.ts` — `new AkanApp("./server", { mcp: { enabled: true } })`. Every field also has an
|
|
1123
|
+
env spelling (`AKAN_MCP`, `AKAN_MCP_READONLY`, `AKAN_MCP_PATH`, `AKAN_MCP_INSTRUCTIONS`, `AKAN_MCP_ALLOWED_ORIGINS`,
|
|
1124
|
+
`AKAN_MCP_LANGUAGE`, `AKAN_MCP_AUTH_SERVERS`, `AKAN_MCP_SCOPES`, `AKAN_MCP_RESOURCE`, …) because the gateway reaches
|
|
1125
|
+
a child process through its environment; a value written in code wins over the env of the same name.
|
|
1126
|
+
|
|
1127
|
+
**Nothing is exposed until it says so, slices included.** A guard stops a call, but the _name and argument schema_
|
|
1128
|
+
of something like `getAccessTokenByAdmin` are themselves worth hiding, and `tools/list` publishes both. Opt in per
|
|
1129
|
+
endpoint with `query(cnst.X, { guards: [...], mcp: { expose: true } })`, per slice with `init({ guards: [...],
|
|
1130
|
+
mcp: { expose: true } })`, and per generated CRUD verb with `slice(srv.x, { guards: {…}, mcp: { get: true } }, …)`
|
|
1131
|
+
— one flag per verb, never a blanket `true`.
|
|
1132
|
+
|
|
1133
|
+
**A tool runs the endpoint's own pipeline, not the service behind it.** `McpExecutionContext` extends the HTTP
|
|
1134
|
+
one, so guards, middleware, internal args, `hidden`/`secret` masking, and serialization all still run: an agent
|
|
1135
|
+
tool is exactly as safe as the endpoint it mirrors, and cannot be more permissive by construction.
|
|
1136
|
+
|
|
1137
|
+
**Refusals are fail-closed and survive opting in** — `pubsub` and `message` (their internal args read a socket an
|
|
1138
|
+
MCP request does not have), an `Any` or `Upload` return, a file upload, a mutation with no real guards, and a
|
|
1139
|
+
required `Any` argument. Each is named in the boot log beside `MCP catalogue: tools=… prompts=…`, along with every
|
|
1140
|
+
entry published with no description and every one published with no guards at all. `akan quality scan` covers the
|
|
1141
|
+
two shapes a source scanner can see, `akan.mcp.missing-description` and `akan.mcp.unguarded-exposure`, and the API
|
|
1142
|
+
explorer badges each endpoint `MCP` / `MCP refused` from the same shared predicate.
|
|
1143
|
+
|
|
1144
|
+
**Three protocol revisions from one stateless handler**: the modern `2026-07-28` and the legacy `2025-11-25` /
|
|
1145
|
+
`2025-06-18`. Supporting the legacy pair turned out to cost almost nothing once it was clear no session store was
|
|
1146
|
+
needed, and dropping it would have disconnected every client shipping today.
|
|
1147
|
+
|
|
1148
|
+
Other pieces:
|
|
1149
|
+
|
|
1150
|
+
- **Resources.** Generated reads publish `akan://<model>/{id}`, `akan://<model>/light/{id}`, `akan://<model>/list`,
|
|
1151
|
+
and `akan://<model>/list/<sliceKey>` alongside their tool. Those four shapes are the whole set — a custom
|
|
1152
|
+
endpoint keeps its tool and is named in the boot log rather than given a template that would resolve to someone
|
|
1153
|
+
else's endpoint.
|
|
1154
|
+
- **`prompt()`**, a fifth endpoint kind: the one a _user_ invokes by name, which a client renders as a slash
|
|
1155
|
+
command. Takes `.param()` and `.search()` only, returns `PromptMessage[]` built with `Msg.user` / `Msg.link` /
|
|
1156
|
+
`Msg.resource` / `Msg.image` and friends. Also mounted as a plain HTTP `GET` whether or not MCP is enabled, so a
|
|
1157
|
+
web UI can preview one — which is why it is in the OpenAPI document and why it must be guarded like any read.
|
|
1158
|
+
An embedded payload is masked by the model you name (`{ model: cnst.LightTask }`), taking the model as an
|
|
1159
|
+
argument so a `{ ...doc }` spread masks as correctly as a hydrated document.
|
|
1160
|
+
- **`McpProgress.report(n, { total, message })`** streams progress from anywhere inside a call — a service, an
|
|
1161
|
+
adapter, a loop several frames down — and is a no-op when nobody is reading, so the same code runs unchanged
|
|
1162
|
+
over HTTP, over a websocket, and in tests.
|
|
1163
|
+
- **Auth** is OAuth 2.0 protected-resource metadata plus a bearer check. A provably unusable token — expired, or
|
|
1164
|
+
audienced elsewhere — is refused up front rather than degraded to anonymous, because an anonymous caller is told
|
|
1165
|
+
a tool does not exist instead of being told to authenticate. A token carrying no `aud` is refused once
|
|
1166
|
+
`AKAN_MCP_AUTH_SERVERS` names an issuer and accepted while none is: that is the confused-deputy case RFC 8707 is
|
|
1167
|
+
a MUST for, whereas a first-party Akan token is already bound by app and environment.
|
|
1168
|
+
- **Descriptions come from the dictionary you already wrote.** The words an agent reads to pick a tool are the
|
|
1169
|
+
same `[en, ko]` pair the UI renders as a label, so an app that localizes well documents itself to agents for
|
|
1170
|
+
free. Generated entries borrow the model's `.of()` and `.desc()`, since none of them has text of its own.
|
|
1171
|
+
- `JsonSchemaBuilder` is now shared with the OpenAPI document via a `refPrefix` option rather than forked, and the
|
|
1172
|
+
exposure predicate lives in `akanjs/common` so the server catalogue and the browser API explorer cannot disagree.
|
|
1173
|
+
- `AgentCatalogue` (`akanjs/signal`) holds the audience-independent half — enumerating a signal registry, holding
|
|
1174
|
+
one name per entry, and resolving dictionary text — so a second agent transport reuses it rather than forking
|
|
1175
|
+
`McpDocument`.
|
|
1176
|
+
|
|
1177
|
+
Errors are reported as the caller's own where they are: a missing, unparseable, or undeclared argument and a
|
|
1178
|
+
document that is not there come back as `isError` naming it, and only a real failure logs a stack. An endpoint
|
|
1179
|
+
that did not opt in answers the same "unknown tool" as one that does not exist, and a guard's refusal never names
|
|
1180
|
+
the guard — the difference between those answers is what would enumerate the private surface.
|
|
1181
|
+
|
|
1182
|
+
- bf27773: feat(devkit): measure and enforce the server/client render split with `akan quality ssr`
|
|
1183
|
+
|
|
1184
|
+
Agent-written UI drifts client-side. The boundary conventions say which file role carries `"use client"`, but
|
|
1185
|
+
nothing said how _little_ should sit behind it, so markup that could ship as HTML kept landing in the bundle.
|
|
1186
|
+
`akan quality ssr` now reports the server render share per app and lib and flags the client code that should have
|
|
1187
|
+
rendered on the server; `akan quality scan` includes both, and `--format json` carries an `ssrBalance` field.
|
|
1188
|
+
|
|
1189
|
+
**The share is measured in JSX elements, not files.** A file count cannot move: the module convention already fixes
|
|
1190
|
+
`Zone`/`Template`/`Util` as client and `Unit`/`View` as server, so the ratio sits near 3:2 whatever an agent
|
|
1191
|
+
writes. Element mass is what actually shifts when rendering moves across the boundary.
|
|
1192
|
+
|
|
1193
|
+
Six rules under a new `ssr` warning scope:
|
|
1194
|
+
|
|
1195
|
+
- `akan.ssr.unnecessary-use-client` — the directive is present but the file uses no hook, event handler, store, or
|
|
1196
|
+
browser API.
|
|
1197
|
+
- `akan.ssr.client-static-component` — a component inside a client file renders markup with zero client-only
|
|
1198
|
+
capability.
|
|
1199
|
+
- `akan.ssr.client-static-markup` — a large subtree wraps one or two interactive touches.
|
|
1200
|
+
- `akan.ssr.client-mount-load` — a `useEffect(…, [])` loads server data the route could have fetched first.
|
|
1201
|
+
- `akan.ssr.module-missing-server-view` — a module renders only from `Template`/`Zone`/`Util` and declares no
|
|
1202
|
+
`Unit`/`View`.
|
|
1203
|
+
- `akan.ssr.template-client-state` — a `Template` holds form state in `useState` instead of the store.
|
|
1204
|
+
|
|
1205
|
+
Three exemptions keep the rules from firing on code that has no server alternative, and each one exists because the
|
|
1206
|
+
naive version was noisy in practice. A file importing a bare third-party package may need the directive for the
|
|
1207
|
+
package's sake, not its own — without this, chart, swiper, markdown, and editor wrappers dominated the output.
|
|
1208
|
+
`ui/<Folder>/index_.tsx` is the declared `lazy()` boundary. And only _mount-time_ loads count as findings: a
|
|
1209
|
+
`fetch.*` inside an `onClick` is an interaction, and a poll inside `useInterval` has no server equivalent, so
|
|
1210
|
+
neither is flagged.
|
|
1211
|
+
|
|
1212
|
+
The 50% server-share target is reported as a metric, not a warning. A scope can be legitimately client-heavy — a lib
|
|
1213
|
+
of third-party wrappers will never reach it — and a threshold that fires on unfixable code trains agents to ignore
|
|
1214
|
+
the tool, so the warnings stay individually actionable and the share stays a scoreboard.
|
|
1215
|
+
|
|
1216
|
+
Guidance ships with it: an **SSR First** section in the workspace `AGENTS.md` (and in the generated-workspace
|
|
1217
|
+
template) covering the cost model, the rule table, and a ten-point server-side playbook — client shells that render
|
|
1218
|
+
`children` untouched, `Tab`/`Tab.Panel` composition that keeps panel content server-rendered, `init`/`view`
|
|
1219
|
+
state-sync instead of mount fetching, passing an unawaited `ClientInit` promise so `Load.*` streams it behind a
|
|
1220
|
+
skeleton, named `ReactNode` slots, derived work on `Light<Model>`, server-side auth gating, and CSS variants in
|
|
1221
|
+
place of client state. The same content is fetchable as the `ssrRule` guideline (`akan guideline show ssrRule`,
|
|
1222
|
+
`get_guideline`) and mirrored into `.cursor/rules/ssr-first.mdc`.
|
|
1223
|
+
|
|
1224
|
+
- 5f53462: `akan subspace upload-env <name>` sends one subspace's env to the cloud workspace it deploys from
|
|
1225
|
+
|
|
1226
|
+
A subspace's `env/` values were the one thing the mirror had no way to move. Push holds them back and pull
|
|
1227
|
+
leaves them alone — deliberately, because the values belong to the repo that deploys — so getting a changed
|
|
1228
|
+
value into a customer's deployment meant running `akan upload-env` from somewhere that had both the values and
|
|
1229
|
+
that customer's `AKAN_WORKSPACE_ID`, which is nowhere: a workspace has every customer's values and exactly one
|
|
1230
|
+
id, its own.
|
|
1231
|
+
|
|
1232
|
+
`SubspaceDeclaration` now carries an optional `workspaceId`, the cloud workspace that subspace deploys from,
|
|
1233
|
+
and `akan subspace upload-env <name>` uploads against it. `akan.subspace.ts` is a workspace-only entry that no
|
|
1234
|
+
push ever ships, so declaring every customer's id there puts none of them in a customer's repo.
|
|
1235
|
+
|
|
1236
|
+
What is uploaded is the slice a push ships — the declared apps, the libraries their closure pulls in, and those
|
|
1237
|
+
apps' `secrets` globs — and nothing else. The whole workspace would be the simpler thing to send, and it is the
|
|
1238
|
+
wrong one: an env archive is replaced whole on arrival, so one customer's cloud workspace would end up holding
|
|
1239
|
+
every other customer's secrets. `CloudRunner.gatherEnvFiles` takes that scope and an archive path (the slice
|
|
1240
|
+
lands in `local/env.<name>.tar`, never over the full `local/env.tar`), while the `akan:secrets` block it
|
|
1241
|
+
maintains in the root `.gitignore` keeps being written from every app in the workspace — narrowing that file to
|
|
1242
|
+
one slice would silently un-ignore every other app's secrets.
|
|
1243
|
+
|
|
1244
|
+
Because the upload replaces an archive in a repo you are not standing in, three things are refused rather than
|
|
1245
|
+
guessed: a subspace with no `workspaceId`, a subspace declaring this workspace's own id (a slice uploaded there
|
|
1246
|
+
would leave the workspace's next `download-env` short of every other app), and two subspaces declaring the same
|
|
1247
|
+
id. The command asks for confirmation before it replaces anything, naming the subspace and the cloud workspace;
|
|
1248
|
+
`-F, --force` skips the question, and with no terminal to ask in the command refuses instead of assuming yes.
|
|
1249
|
+
(`-f` is already `--format` and `-y` already `--verify`.)
|
|
1250
|
+
|
|
1251
|
+
The direction is one-way on purpose. There is no `subspace download-env`: it would write a customer's values
|
|
1252
|
+
over the workspace's own copies, and which of the two is right is not something a command can know.
|
|
1253
|
+
|
|
1254
|
+
- 5f53462: The page shows what the agent is doing to it
|
|
1255
|
+
|
|
1256
|
+
An in-page agent's work used to be legible only in the transcript, and the transcript is behind a panel that is
|
|
1257
|
+
closed as often as it is open. A form that fills itself, a tab that switches, a route that changes — each arrived
|
|
1258
|
+
with nothing anywhere attributing it. Now the page itself says so, from the one signal that knows a call is
|
|
1259
|
+
running rather than from a store action, which cannot tell the agent's write from the user's.
|
|
1260
|
+
|
|
1261
|
+
Two effects, both on by default. **The control a call was published from is ringed** where it stands, scrolled to
|
|
1262
|
+
first when it is off screen — no app code, because the same `onChange={st.do.setTitleOnTask}` reference that earns
|
|
1263
|
+
`data-akan-action` is what makes the control findable. And **a pointer travels to it and presses it**: the ring
|
|
1264
|
+
answers _where_, the pointer answers _who_. It glides only when the hop is far enough to be worth following,
|
|
1265
|
+
presses on arrival rather than on departure, and fades when the batch is over.
|
|
1266
|
+
|
|
1267
|
+
A call that reaches no control draws nothing, `navigate` included — the router is not an element.
|
|
1268
|
+
|
|
1269
|
+
The form patch fans out: `fill<Model>Form` rings one control per field it named, resolved through the same
|
|
1270
|
+
`data-akan-state` the setter annotates, capped at five — a patch of twenty is a form being filled, not twenty
|
|
1271
|
+
events.
|
|
1272
|
+
|
|
1273
|
+
`visual` on `Agent.Chat` and `Agent.Zone` turns it off (`false`) or turns one effect off
|
|
1274
|
+
(`visual={{ cursor: false }}`). Nothing is ever waited on: a call starts the moment its event is handed over.
|
|
1275
|
+
|
|
1276
|
+
A tool a name several rows answer to rings nothing rather than guessing a row, a call an approval or a guard
|
|
1277
|
+
turned back is never drawn, and a batch past four calls stops scrolling and keeps ringing.
|
|
1278
|
+
|
|
1279
|
+
`ToolRunner` gained an `activity` host channel and `AgentSession` an `onActivity` option, both carrying
|
|
1280
|
+
`ToolActivity`. They are separate from `progress`, which only ever fires for a tool that chose to report — and a
|
|
1281
|
+
tool that says nothing about itself is exactly the one whose effect arrives unexplained.
|
|
1282
|
+
|
|
1283
|
+
**`highlight` now waits for its scroll.** `ScreenFlash` — the extracted reveal-and-ring both effects share — fixes
|
|
1284
|
+
a settle loop seeded with `NaN`, which made every first-frame comparison false and put the ring on at the moment
|
|
1285
|
+
the smooth scroll _started_. On a long page it had already faded by the time the user's eye arrived.
|
|
1286
|
+
|
|
1287
|
+
- ccf2ae2: The volume plugin reads and sets Android's media volume
|
|
1288
|
+
|
|
1289
|
+
On Android, `volume` from `akanjs/client/native` drives the media volume (STREAM_MUSIC), the one the volume keys move
|
|
1290
|
+
while the app plays: `getVolume()`, `setVolume({ level })`, `setMuted({ muted })` and the `change` event behave as on
|
|
1291
|
+
the desktop. Setting the level keeps the mute as it was (Android unmutes a stream whose volume changes), and a device
|
|
1292
|
+
with a fixed volume answers `settable: false`. iOS still answers `UNSUPPORTED`.
|
|
1293
|
+
|
|
1294
|
+
- ccf2ae2: `trustedDependencies` in `akan.config.ts` names the packages whose install scripts run where the app is installed
|
|
1295
|
+
|
|
1296
|
+
`bun install --production` skips every dependency's install and postinstall scripts unless the package is trusted, so
|
|
1297
|
+
a native addon that builds itself at install time (one that ships no prebuilds) reached the image unbuilt and failed
|
|
1298
|
+
at its first call. An app or a lib now lists those packages in `trustedDependencies`; the built `package.json`
|
|
1299
|
+
carries the list, and both the image and a desktop app's server run their scripts. A lib's list reaches every app.
|
|
1300
|
+
|
|
1301
|
+
- ccf2ae2: `usePurchase` sells through the native in-app purchase plugin: StoreKit 2 on iOS, Play Billing on Android.
|
|
1302
|
+
|
|
1303
|
+
- `usePurchase({ platform, productInfo, url, onPay, onSubscribe })` answers `{ isLoading, products,
|
|
1304
|
+
purchaseProduct, restorePurchases }`. `purchaseProduct(product | id, offerToken?)` resolves `"purchased"`,
|
|
1305
|
+
`"pending"`, `"cancelled"` or `"unverified"`; a shell on a platform the app does not sell in asks the store
|
|
1306
|
+
nothing, and the web sells nothing.
|
|
1307
|
+
- A transaction is verified by `POST <url>/billing/verifyBilling` with `{ data }`, handed to `onPay` or
|
|
1308
|
+
`onSubscribe`, and only then finished (a consumable consumed, anything else acknowledged). A refused
|
|
1309
|
+
verification or a failing callback leaves it unfinished, so the store hands it over again; what the last launch
|
|
1310
|
+
left unfinished is settled at mount, and one transaction is never credited twice.
|
|
1311
|
+
|
|
1312
|
+
**Breaking.** The hook's options and answer changed, and `cordova-plugin-purchase` is no longer a peer. iOS sends
|
|
1313
|
+
the transaction's JWS alone as `receipt` (no app receipt, no `accountId`), so the verification server has to accept
|
|
1314
|
+
the App Store Server API form in the same release.
|
|
1315
|
+
|
|
1316
|
+
- 5f53462: Web push has one owner, and it draws once
|
|
1317
|
+
|
|
1318
|
+
`/firebase-messaging-sw.js` was a framework route that built the worker in memory from `env.client.ts` on every
|
|
1319
|
+
request. It matched ahead of the static fallback, so an app's own `public/firebase-messaging-sw.js` was never
|
|
1320
|
+
served and there was no way to turn it off — including the one the push plugin's `syncAssets` had already written
|
|
1321
|
+
there, which meant editing that file changed nothing a browser ever saw. The route is gone. The plugin owns the
|
|
1322
|
+
worker, `akan sync` writes it, and the static fallback serves it.
|
|
1323
|
+
|
|
1324
|
+
Because the plugin writes a file rather than answering a request, it now rewrites a worker whose body has changed
|
|
1325
|
+
instead of skipping any file that exists — otherwise a fix here would never reach an app that had synced once. The
|
|
1326
|
+
file opens with a banner; remove that line and sync leaves the file alone, which is how an app takes the worker
|
|
1327
|
+
over.
|
|
1328
|
+
|
|
1329
|
+
The worker itself had four bugs, all of them the kind that only show up with a real device in hand:
|
|
1330
|
+
|
|
1331
|
+
- **It never activated on time.** With no `install`/`activate` handlers a visitor who already had a worker got the
|
|
1332
|
+
new one on the visit _after_ their next one. It now calls `skipWaiting()` and `clients.claim()`.
|
|
1333
|
+
- **One push arrived as two notifications.** firebase-js-sdk draws a payload carrying a `notification` block
|
|
1334
|
+
itself and _then_ calls `onBackgroundMessage`, so drawing there unconditionally doubled it — visible as a
|
|
1335
|
+
duplicate without a `tag`, and as a re-alert with one. The handler is now data-only.
|
|
1336
|
+
- **Every click opened a new tab**, and a full reload rather than a route change. It now focuses an open window
|
|
1337
|
+
and hands the path over; `openWindow` is the fallback, not the default.
|
|
1338
|
+
- **A badge count in `data.badgeCount` went nowhere.** The worker applies it through `setAppBadge`, guarded —
|
|
1339
|
+
Firefox and desktop Safari ship no such method and an unguarded call is a synchronous `TypeError`.
|
|
1340
|
+
|
|
1341
|
+
`AkanSyncContext` gained `readFile`, the pair of the `fileExists`/`writeFile` it already had, so a plugin can tell
|
|
1342
|
+
what it is about to overwrite.
|
|
1343
|
+
|
|
1344
|
+
The app-side half ships in `libs/util`, which is where the worker and the hook now live. FCM hands a payload to
|
|
1345
|
+
the page instead of the worker whenever a visible window client exists, and `usePushNotification` listened only in
|
|
1346
|
+
the worker — so a push sent while the app was on screen was dropped outright, with FCM reporting the send as
|
|
1347
|
+
delivered, which is what makes that one expensive to diagnose. The hook now installs `onMessage` and draws through
|
|
1348
|
+
the worker's registration, receives the worker's click handover, and routes it through `router.enterDeepLink`.
|
|
1349
|
+
|
|
1350
|
+
- ccf2ae2: A Windows desktop release is signed with Authenticode
|
|
1351
|
+
|
|
1352
|
+
With `AKAN_NATIVE_WINDOWS_CERTIFICATE` + `_CERTIFICATE_PASSWORD` (a `.pfx`), `AKAN_NATIVE_WINDOWS_THUMBPRINT` (a
|
|
1353
|
+
certificate in the store) or `AKAN_NATIVE_WINDOWS_SIGN_COMMAND` (a JSON array run once per file with `{file}`, for Azure
|
|
1354
|
+
Trusted Signing or a cloud HSM), `akan build-desktop` signs every PE file of the app — the executable, its DLL, the
|
|
1355
|
+
server's addons and `bin` — SHA-256 with an RFC 3161 timestamp (`AKAN_NATIVE_WINDOWS_TIMESTAMP_URL`), and verifies them.
|
|
1356
|
+
`--installer` signs the setup program and, through makensis's `!uninstfinalize`, the uninstaller it writes; the
|
|
1357
|
+
settings reach the signer through the environment only. `publish-update` signs a Windows release the same way, and an
|
|
1358
|
+
unsigned release build warns that SmartScreen flags a downloaded copy.
|
|
1359
|
+
|
|
1360
|
+
### Patch Changes
|
|
1361
|
+
|
|
1362
|
+
- ccf2ae2: A CSR page or layout whose async render throws says so, a cached page whose layout redirected renders again, and a
|
|
1363
|
+
dev client module that imports a macro builds.
|
|
1364
|
+
|
|
1365
|
+
- An async render that rejected used to leave its layer blank with nothing in the console, since the promise's
|
|
1366
|
+
failure was dropped. It is now logged with the stack and the layer it belongs to (`render of page 2 of
|
|
1367
|
+
/:lang/home failed: …`).
|
|
1368
|
+
- `akan:debug:frame` takes `memory` besides `1`: the frame trace goes to `window.__AKAN_FRAME_TRACE__` (the last
|
|
1369
|
+
2000 events) instead of the console. A native dev build mirrors each console call over the bridge, which slows the
|
|
1370
|
+
frame enough to hide a timing bug; `memory` does not. Async page and layout renders add `layer.render`,
|
|
1371
|
+
`layer.settle` and `layer.fail` events.
|
|
1372
|
+
- A cached page whose layout redirected renders that layout again when it is back on screen. Signed out, a
|
|
1373
|
+
layout that sends the person to the sign-in page kept its empty result, so the page stayed blank after the sign-in
|
|
1374
|
+
brought them back (a native app opens its home first, so it always did). A render that issued a redirect while it
|
|
1375
|
+
ran is now rerun the next time its page becomes current; `router.redirectCount()` is what it compares.
|
|
1376
|
+
- `window.__AKAN_DUMP_FRAME__()` reports the frame's location, stack and phase and every recent async page and
|
|
1377
|
+
layout render: settled or pending, still awaited, drawn. It records only the latest state, so it does not change
|
|
1378
|
+
the timing a trace would.
|
|
1379
|
+
- `akan start` no longer fails a dev client build with `ReferenceError: __akanMeta is not defined` when a file
|
|
1380
|
+
imports a macro (`useClient.ts`'s `with { type: "macro" }`) whose modules read `import.meta` at the top level.
|
|
1381
|
+
Bun applied the dev build's `import.meta` rewrite to the macro's modules too, and ran them where nothing declared
|
|
1382
|
+
the rewritten name; such a file now has its macros run by a build of its own first.
|
|
1383
|
+
|
|
1384
|
+
- ccf2ae2: A native dev build's API calls and sockets go through the dev gateway, so a phone on the same network — or the
|
|
1385
|
+
simulator, the emulator and a desktop app — reaches `akan start` without a reversed port.
|
|
1386
|
+
|
|
1387
|
+
- A page the gateway served calls its own origin: `getEnv().serverHttpUri` is `<page origin><api prefix>` and the
|
|
1388
|
+
websocket follows it, unless `AKAN_PUBLIC_SERVER_URL` names a server. A release build is unchanged.
|
|
1389
|
+
- The iOS, Android and desktop hosts carry the method, the body and the request headers, `authorization`
|
|
1390
|
+
included, to the gateway; the gateway relays the app's `<prefix><websocketPrefix>` socket. Android hands the
|
|
1391
|
+
host no request body, so a call that carries one goes to the gateway's `http://localhost` origin directly and
|
|
1392
|
+
the server answers its CORS.
|
|
1393
|
+
- A desktop dev build opens the target's start page instead of `/`, and builds when the web root has no
|
|
1394
|
+
`index.html` yet.
|
|
1395
|
+
- `NativeWebDir` lists the web root in `/`-separated paths on Windows too.
|
|
1396
|
+
|
|
1397
|
+
- 5f53462: A relation field renders the value it holds
|
|
1398
|
+
|
|
1399
|
+
`Field.Parent` / `ParentId` / `Children` / `ChildrenId` built their options out of the slice list alone, and that
|
|
1400
|
+
list is loaded when the dropdown opens. A form filled by `edit<Model>` therefore rendered an empty control over a
|
|
1401
|
+
value it was holding — and opening the dropdown did not always fix it, because a slice list is one `limit`-sized
|
|
1402
|
+
window and the row the value points at can sit outside it.
|
|
1403
|
+
|
|
1404
|
+
`Select` was the immediate cause: a selected value with no matching option rendered nothing at all, `renderSelected`
|
|
1405
|
+
included, so no amount of care in the caller could have painted it. It now renders the value it is given, which is
|
|
1406
|
+
what a controlled component owes its parent — an enum that lost a member shows the stale value rather than a blank
|
|
1407
|
+
field.
|
|
1408
|
+
|
|
1409
|
+
The four controls now merge what they hold into their options. A `Light`-valued control already carries the row, so
|
|
1410
|
+
it needs no request and the value is right in the server render. An id-valued control carries no row at all, so a
|
|
1411
|
+
missing one is read through the generated `light<Model>` endpoint and cached per `refName:id` — deliberately beside
|
|
1412
|
+
the store rather than in it, because a model store is a singleton and resolving into it would overwrite whatever
|
|
1413
|
+
listing of the same model the screen is already showing. A row that cannot be read — removed, or behind narrower
|
|
1414
|
+
`get` guards than the slice the control lists from — is remembered as unreadable rather than retried, and the
|
|
1415
|
+
control shows the id.
|
|
1416
|
+
|
|
1417
|
+
Four things came with it, all of them the same bug seen from another side:
|
|
1418
|
+
|
|
1419
|
+
- Opening the dropdown no longer invalidates the slice list. It refreshed on every open, replacing the rows any
|
|
1420
|
+
`Load.Units` on the same page had put on screen; it now reuses what the store already loaded.
|
|
1421
|
+
- `Field.ParentId` offered its options as bare ids, so `searchable` matched on the hex id while the option on
|
|
1422
|
+
screen read as a name. Every control now labels its options — from `renderOption` when that returns a string,
|
|
1423
|
+
otherwise from the model's own `labelOf`.
|
|
1424
|
+
- `sortOption` was accepted by all four and applied by none.
|
|
1425
|
+
- `Select` takes `loading`, and the controls pass the slice's loading flag, so an opened dropdown says it is
|
|
1426
|
+
fetching instead of claiming there is nothing to pick.
|
|
1427
|
+
|
|
1428
|
+
- ccf2ae2: fix: a gateway's replica takes the websocket relay on 127.0.0.1, from the gateway only
|
|
1429
|
+
|
|
1430
|
+
Behind the gateway a replica listens for relayed sockets on a TCP port of its own, and its ready message names
|
|
1431
|
+
127.0.0.1, where the gateway dials. It bound that port on every interface; it now binds 127.0.0.1 only, whatever
|
|
1432
|
+
`AKAN_LISTEN_HOST` names, and answers only the Host the gateway's hop carries, so a page on a name rebound to
|
|
1433
|
+
127.0.0.1 is refused there too.
|
|
1434
|
+
|
|
1435
|
+
- ccf2ae2: fix: a server whose parent dies while it boots exits instead of serving on
|
|
1436
|
+
|
|
1437
|
+
A replica (and a desktop app's carried server) listened for its parent's IPC channel closing only after its `init`
|
|
1438
|
+
finished, and Bun drops that event for a listener added after a `message` listener, which the single-mode queue and
|
|
1439
|
+
pub/sub add during `init`: a gateway or shell killed during boot left the server running, holding its port and its
|
|
1440
|
+
database, with its cron jobs. It now listens before `init`, treats a channel already closed or a `ready` it could not
|
|
1441
|
+
send as a parent gone, and ends its process group the same way it does after boot.
|
|
1442
|
+
|
|
1443
|
+
- 5f53462: fix: a navigation to a route that is not there leaves the page alone
|
|
1444
|
+
|
|
1445
|
+
The RSC route answers a target that resolves to nothing with `0:null` under a 404 — a Flight payload whose root is
|
|
1446
|
+
literally `null`. `fetchRscNavigationResponse` read the redirect and patch headers but never the status, so that
|
|
1447
|
+
payload was decoded like any other and committed: the client replaced the whole document with an empty tree. Since
|
|
1448
|
+
the tree is mounted by `hydrateRoot(document, …)`, everything went with it — the root layout, and whatever it holds,
|
|
1449
|
+
an in-page agent's chat session included. A full document load never showed this, because that path renders a real
|
|
1450
|
+
not-found page instead; only client navigation reached the empty one, so the same bad href behaved completely
|
|
1451
|
+
differently depending on how it was followed.
|
|
1452
|
+
|
|
1453
|
+
The status is now read before the body reaches the decoder, and a not-found navigation is **refused rather than
|
|
1454
|
+
committed**: nothing is rendered, history is never touched, and the page stays exactly where it was. It is
|
|
1455
|
+
deliberately not converted into a document navigation either — that would land on the 404 this refusal exists to
|
|
1456
|
+
avoid. `Router.navigation()` is the new report: it answers the navigation `push` / `replace` last started and
|
|
1457
|
+
rejects when the route refused to move, which `push` itself cannot do because it returns before the payload for the
|
|
1458
|
+
new route has even been asked for. `popstate` is the one exception and goes the other way — the address bar has
|
|
1459
|
+
already moved, so refusing would leave the tree and the URL describing different pages, and a document navigation
|
|
1460
|
+
there renders the real not-found page.
|
|
1461
|
+
|
|
1462
|
+
The in-page agent's `navigate` reads that report and answers the model with the miss instead of a move that did not
|
|
1463
|
+
happen, so a guessed path costs one tool error on the screen the agent is still on rather than the session.
|
|
1464
|
+
|
|
1465
|
+
- ccf2ae2: fix: a file `getBlob` serves never runs as a page of the API's origin
|
|
1466
|
+
|
|
1467
|
+
`libs/util`'s `getBlob` (`/api/localFile/getBlob/*`) sets no Content-Type of its own: Bun types the body by its
|
|
1468
|
+
stored name and answers a Range with a 206, and the response goes out the way Bun sends a file, neither buffered nor
|
|
1469
|
+
compressed. Every answer but a PDF's carries `Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline';
|
|
1470
|
+
sandbox` and `X-Content-Type-Options: nosniff`, so an uploaded `.html` or `.svg` opened from its URL runs no script on
|
|
1471
|
+
the API's origin, which is also the origin of a desktop app's carried server. An `<img>` or `<video>` showing the file
|
|
1472
|
+
is unaffected; a PDF is left out because a browser's viewer refuses a sandboxed document.
|
|
1473
|
+
|
|
1474
|
+
- 69f7178: fix: make the admin panel's own controls work, and give the data surface a look
|
|
1475
|
+
|
|
1476
|
+
`Model.AdminPanel` is the one component an app mounts to get a model's whole admin surface, and most of what it
|
|
1477
|
+
put on screen did nothing.
|
|
1478
|
+
|
|
1479
|
+
**The dashboard crashed the panel on every app that has no `summary` state.** `AdminPanel` always passed a
|
|
1480
|
+
`renderDashboard`, and `ListContainer` read the tiles it needs through `st.use.summary()` — an app-level store key,
|
|
1481
|
+
not a generated one — so the accessor was `undefined` and calling it took the route down. It now reads the key off
|
|
1482
|
+
the state through `st.sel`, which is one hook whether or not the key exists, and `AdminPanel` defaults a dashboard
|
|
1483
|
+
only when `summaryColumns` names something to put in it.
|
|
1484
|
+
|
|
1485
|
+
**A summary tile never rendered even where the state existed.** `AdminPanel` declared a `queryMap` prop and passed
|
|
1486
|
+
`{}` in its place, and `Dashboard` skipped every column the map did not name. The map is forwarded, and it now
|
|
1487
|
+
decides whether a tile _links_ to its filtered listing rather than whether the tile exists at all. The tile is also
|
|
1488
|
+
no longer an `<a>` inside a `<button>`.
|
|
1489
|
+
|
|
1490
|
+
**The sort selector was permanently empty.** It read `fetch.<model>SortKeys`, which nothing defines, and labelled
|
|
1491
|
+
the options with a dictionary key one level short of the real one. Sort keys now travel with the serialized signal
|
|
1492
|
+
(`FetchSerializer` emits `filter.sortKeys`, which `FetchClient` already knew how to merge and register), the
|
|
1493
|
+
selector reads `fetch.sortKeyMap`, and a label that misses the dictionary falls back to the humanized key instead of
|
|
1494
|
+
printing `user.latest` at the reader.
|
|
1495
|
+
|
|
1496
|
+
**`query`, `init`, and `sort` were dead props.** The init effect called `init<Model>()` with no arguments at all.
|
|
1497
|
+
It now fills every slice argument before the init form, which is the only order the generated action reads
|
|
1498
|
+
correctly — passing the form into an unfilled positional slot made it the query.
|
|
1499
|
+
|
|
1500
|
+
Also: an `Export CSV` that emitted tab-separated JSX (`[object Object]` for any column with a renderer) is real,
|
|
1501
|
+
escaped, BOM-prefixed CSV read from the column's `value`; a card column with no `.length` — every number, every
|
|
1502
|
+
date — renders instead of silently vanishing; a table header with no dictionary entry reads `Created At` instead of
|
|
1503
|
+
`user.createdAt`; an empty card list says so instead of rendering nothing; a table keeps its rows while refreshing
|
|
1504
|
+
instead of flashing its empty state; and the toolbar, tiles, and cards are rebuilt on the semantic tokens with a
|
|
1505
|
+
card/table toggle, so the panel looks like the rest of the framework.
|
|
1506
|
+
|
|
1507
|
+
- 69f7178: feat!: `setAgentAccess` takes guard classes, not a policy function
|
|
1508
|
+
|
|
1509
|
+
Who may spend the LLM key through the `runAgentTurn` relay is an authorization decision, and this codebase spells
|
|
1510
|
+
those as guard classes everywhere else. `AgentRelayAccess` now forwards to the guards an app names instead of
|
|
1511
|
+
calling a bespoke `(context) => boolean` — so the relay reuses the same `srvkit/guards.ts` classes an endpoint's
|
|
1512
|
+
own `guards: [...]` array takes, rather than making the app restate `!!context.get("account")` in a shape nothing
|
|
1513
|
+
else in the framework understands.
|
|
1514
|
+
|
|
1515
|
+
```ts
|
|
1516
|
+
// before
|
|
1517
|
+
option.setAgentAccess((context) => !!context.get("account"));
|
|
1518
|
+
// after
|
|
1519
|
+
option.setAgentAccess(SignedIn);
|
|
1520
|
+
option.setAgentAccess([SignedIn, NotBanned]); // ANDed, as an endpoint's own array is
|
|
1521
|
+
```
|
|
1522
|
+
|
|
1523
|
+
`null` still clears what a library set, and with nothing named the call is still refused like `None`. The
|
|
1524
|
+
`AgentRelayPolicy` type is gone. `AgentRelayAccess.scope` became a getter reporting whatever its delegates need,
|
|
1525
|
+
so a resource-scoped guard cannot be evaluated argument-free by a catalogue that believed the old static
|
|
1526
|
+
`"account"`. Guard instances are now cached by `guardOf` in `signal/guard.ts`, which `SignalContext` uses too —
|
|
1527
|
+
one instance per class, built on first use rather than at registration.
|
|
1528
|
+
|
|
1529
|
+
- 69f7178: feat: the in-page agent can ask the user instead of guessing
|
|
1530
|
+
|
|
1531
|
+
`askUser` is a fourth built-in on every turn, and the **session** owns it rather than the surface: the answer comes
|
|
1532
|
+
from the conversation, not the screen, so it needs no declaration from the page and a zone agent asks inside its own
|
|
1533
|
+
transcript. `AgentSession` parks the loop on `pendingQuestion` exactly as it parks on `pendingApproval`, and
|
|
1534
|
+
`Agent.Chat` renders the question card above the composer — which is closed anyway while a turn runs, so the card is
|
|
1535
|
+
the only way in.
|
|
1536
|
+
|
|
1537
|
+
`choices` offers a pick (`multiple` for several) and omitting them asks for free text. The card keeps a free-text row
|
|
1538
|
+
either way, because the model wrote the options and only the user knows whether the right answer is among them.
|
|
1539
|
+
Dismissing is the tool's **error** result rather than a silent empty answer, so a model that asked cannot read a
|
|
1540
|
+
skipped question as consent; an aborted turn settles the same way. A question with no text is refused without ever
|
|
1541
|
+
reaching the screen, and choices are trimmed and deduped because the answer is the option's own text — two identical
|
|
1542
|
+
options cannot be told apart.
|
|
1543
|
+
|
|
1544
|
+
A settled ask renders as the exchange it was, question then answer, instead of as a `askUser` tool row: while the
|
|
1545
|
+
question is pending only the card holds it, so the text is never on screen twice. A hook tool named `askUser` shadows
|
|
1546
|
+
the built-in like any other, and the relay needed no change — the tool rides the same wire the surface's own do.
|
|
1547
|
+
|
|
1548
|
+
- 5f53462: An attachment carrying both bytes and an address is read from the bytes
|
|
1549
|
+
|
|
1550
|
+
Both vision adaptors picked `url` over `data` — `AnthropicLlm.sourceOf` and the OpenAI dialect's image part — so a
|
|
1551
|
+
host could not send an address for the screen and bytes for the model in one attachment. Sending both got the
|
|
1552
|
+
address, and when that address was private the model answered about a picture nothing had fetched, with no error
|
|
1553
|
+
anywhere. That is the one attachment failure that reports nothing, and the framework's own default storage backend
|
|
1554
|
+
serves exactly such a path.
|
|
1555
|
+
|
|
1556
|
+
The order is now bytes first in both. Choosing the address does save the provider hop what the bytes weigh, so this
|
|
1557
|
+
is not free — but a host that has a publicly reachable URL has no reason to also inline the bytes, while a host
|
|
1558
|
+
that uploaded to private storage has every reason to send both. Being wrong the new way costs a larger request;
|
|
1559
|
+
being wrong the old way cost a confident answer about nothing. A provider-reachable URL should still travel alone.
|
|
1560
|
+
|
|
1561
|
+
- 5f53462: An attachment can carry the host's own handle on the file, and the composer's ceilings are the app's
|
|
1562
|
+
|
|
1563
|
+
`MessageAttachment.ref` is opaque to the framework, which only moves it — a file id, a storage key, whatever turns
|
|
1564
|
+
the attachment back into something a tool can be handed. Without one, a host that stores its uploads keeps a map
|
|
1565
|
+
beside the transcript keyed on name and size, which is the guess `Attachment.same` has to make and the one that is
|
|
1566
|
+
wrong for two crops of one export; when both sides carry a `ref`, that is now the answer instead.
|
|
1567
|
+
|
|
1568
|
+
The per-file ceiling is measured on what `attach` produced rather than on the file that was picked, so a reader
|
|
1569
|
+
that uploads and answers a `url` is no longer refused for a cost it does not incur. All three ceilings become
|
|
1570
|
+
defaults behind `<Agent.Chat attachLimits={{ perFileBytes, perMessageBytes, perMessageCount }} />`, since what one
|
|
1571
|
+
request can carry belongs to the configured provider.
|
|
1572
|
+
|
|
1573
|
+
A `url` the reader answers is handed to the provider as the address it will fetch, which is now documented on
|
|
1574
|
+
`attach`: the default storage backend serves a path only the app can resolve, and a model given one answers about
|
|
1575
|
+
a picture it never saw with nothing anywhere reporting a failure. Answer `data` when the provider cannot reach it.
|
|
1576
|
+
|
|
1577
|
+
- 5f53462: An attachment an app built itself cannot kill the turn
|
|
1578
|
+
|
|
1579
|
+
`session.send([{ role: "user", text, attachments }])` lets a host assemble attachments directly, and until now
|
|
1580
|
+
`MessageAttachment` was not exported from `akanjs/ui` — so the one path where the app writes the object was the one
|
|
1581
|
+
path with no type to write it against, while the composer's own reader was typed. It is exported now.
|
|
1582
|
+
|
|
1583
|
+
Two places trusted that object's declared shape. `AgentService.isReadable` dereferenced `mimeType` unguarded, so a
|
|
1584
|
+
missing one threw out of the relay and took the whole turn — every other attachment of the message with it — under
|
|
1585
|
+
a `TypeError` that named nothing; it is now unreadable like any other type the provider cannot take, which routes
|
|
1586
|
+
it through the note that names the file. The attachment chip did the same on the preview branch, where the throw
|
|
1587
|
+
took the transcript's render down. Both now cost that one attachment and nothing else.
|
|
1588
|
+
|
|
1589
|
+
`field.hidden` is documented as the trap behind this: it is stripped from every endpoint response and hydration
|
|
1590
|
+
writes `null` over the key, while the generated type still declares the field — so a client holds a deliberate
|
|
1591
|
+
`null` behind a type that promises a value, and only `??` / `== null` catch it.
|
|
1592
|
+
|
|
1593
|
+
- 5f53462: feat: the in-page agent issues independent tool calls in one turn instead of one per turn
|
|
1594
|
+
|
|
1595
|
+
A turn has always been able to carry several tool calls — the session collects every `toolCall` event, runs them in
|
|
1596
|
+
order and posts them back as one `tool` message — but nothing asked the model to use it. A screen that needed ten
|
|
1597
|
+
calls got ten turns, each paying a full model round trip and a resend of the whole transcript, and the turn cap
|
|
1598
|
+
then parked the run on a "keep going?" card halfway through.
|
|
1599
|
+
|
|
1600
|
+
- `AgentService.preamble` is the framework's own half of the system prompt, ahead of whatever the app declared. It
|
|
1601
|
+
asks for the batch — every call that does not need another call's result goes in the same turn, and a tool that
|
|
1602
|
+
does the whole job in one call (a form's fill tool) beats one call per field — and it asks the model not to read
|
|
1603
|
+
the screen back to confirm work it has just done, since the change report it was handed already says so. Both
|
|
1604
|
+
sentences were measured against the provider: "approve these eight" went from 1.5 turns (with three runs in eight
|
|
1605
|
+
doing nothing at all) to one turn in every run, and "read the screen, then approve what is pending" from 3.0
|
|
1606
|
+
turns to its floor of 2.0 in every run. The read a fresh route needs after `navigate` is acquisition rather than
|
|
1607
|
+
confirmation, and survived in every run of that scenario. It is composed in the service rather than in an adaptor
|
|
1608
|
+
for the reason `explained` is — an adaptor that has to remember it is one that forgets.
|
|
1609
|
+
- The turn cap defaults to 12 assistant turns instead of 8. A chain of ten calls could not finish under the old
|
|
1610
|
+
default without the user answering a question first.
|
|
1611
|
+
- `readState` and `highlight` declare `settle: false`, as `readScreen` already did. A settle is 120ms of DOM quiet
|
|
1612
|
+
at the very least, and neither of them changes anything a resource holds, so ten reads paid a second and a bit
|
|
1613
|
+
for a change report that is empty by construction.
|
|
1614
|
+
|
|
1615
|
+
- 5f53462: feat: a tool the user answers, rendered in the chat by the app that declared it
|
|
1616
|
+
|
|
1617
|
+
`st.tool("collectContact").desc("…").card(({ submit, cancel }) => <Contact.Form …/>)` is a second way a tool chain
|
|
1618
|
+
ends. Where `.exec()` runs a function, a card parks the call in the chat and renders the app's own component there;
|
|
1619
|
+
what the form submits is the call's result, and cancelling is the error the model reads instead. A name and a phone
|
|
1620
|
+
number, a date somebody has to look up, a signature — those are answers a model must not invent, and until now the
|
|
1621
|
+
only surfaces a chat could park on were the approval gate and `askUser`'s question, neither of which carries a
|
|
1622
|
+
shape.
|
|
1623
|
+
|
|
1624
|
+
`AgentSession` holds it as `pendingCard` beside `pendingApproval` and `pendingQuestion`, and `Agent.Chat` renders it
|
|
1625
|
+
in the same place; `AgentToolCard` is an `_overrides.tsx` slot like the other two. The frame draws its own way out
|
|
1626
|
+
even when the app's component does not, so a turn can never park on something the user cannot dismiss.
|
|
1627
|
+
|
|
1628
|
+
The call waits **outside** the tool queue, for the reason an approval does: a form parked in front of somebody is
|
|
1629
|
+
not work, and holding the execution lock across it would freeze every other agent on the page behind one unanswered
|
|
1630
|
+
card. The declared arguments are checked before the card is parked rather than while it renders — a throw inside the
|
|
1631
|
+
host's tree would take the chat down, where a verdict reaches the model as something it can correct — and `confirm`
|
|
1632
|
+
is not read for a card at all, because the card is already the asking. The screen is still snapshotted around the
|
|
1633
|
+
wait, so a card that writes what it collected into a store reports what moved like any other call.
|
|
1634
|
+
|
|
1635
|
+
- f882bbc: feat: the in-page chat is customizable per part, opens from the app's own control, and composes multi-line
|
|
1636
|
+
|
|
1637
|
+
**Eight slots replace what the chat renders, so a skin no longer means replacing the panel.** `AgentLauncher`,
|
|
1638
|
+
`AgentBubble`, `AgentComposer`, `AgentApproval`, `AgentQuestion`, `AgentMenu`, `AgentMarkdown` and `AgentCode`
|
|
1639
|
+
each bind in `_overrides.tsx` beside the existing `AgentChat`, and `akanjs/ui` exports every default next to them
|
|
1640
|
+
(`DefaultBubble`, `DefaultComposer`, …) so a replacement composes the one it is replacing instead of
|
|
1641
|
+
re-implementing the loop, the slash commands and the approval gate. `AgentCode` is the seam a highlighter binds
|
|
1642
|
+
to — the markdown scanner now keeps the fence's language, which it used to drop. A component bound to
|
|
1643
|
+
`AgentBubble` carries its own `memo`: the transcript re-renders on every streamed delta.
|
|
1644
|
+
|
|
1645
|
+
**The panel takes a controlled `open`/`onOpenChange` pair, so an app opens the chat from its own control.**
|
|
1646
|
+
Conditional mounting was not a substitute — unmounting aborts the session and discards the conversation.
|
|
1647
|
+
`launcher={false}` draws no launcher for an app that has its own entry point, `launcherClassName` /
|
|
1648
|
+
`panelClassName` reach one surface each where `className` reaches both, `intro` replaces the empty-state line
|
|
1649
|
+
(where starter questions go) and `header` adds controls beside the built-in clear and close.
|
|
1650
|
+
|
|
1651
|
+
**The composer is a textarea**: Enter sends, Shift+Enter writes a newline, and it grows to a few lines before it
|
|
1652
|
+
scrolls. The vertical arrows still walk what was sent, but only from the first or last line — anywhere else the
|
|
1653
|
+
caret belongs to the textarea. It resolves its field shell through the `input` recipe slot, so a recipe swap
|
|
1654
|
+
reaches it like every other field.
|
|
1655
|
+
|
|
1656
|
+
**On a phone the panel is the whole screen**, a card from `sm:` up, and it lifts above the on-screen keyboard —
|
|
1657
|
+
only `visualViewport` reports that inset, and a full-screen chat whose composer sits under the keyboard is one
|
|
1658
|
+
nobody can type into.
|
|
1659
|
+
|
|
1660
|
+
`SessionContext`, `useAgent`, `agentSessionOf`, `ChatCommands` and the parts' prop types are exported too: an app
|
|
1661
|
+
may not import `use-agentic`, and without them a replacement could not see the session an `Agent.Zone` handed down.
|
|
1662
|
+
|
|
1663
|
+
- 5f53462: The in-page chat takes the next message while a turn is still running.
|
|
1664
|
+
|
|
1665
|
+
- Enter (or the Queue button that appears beside Stop) during a turn parks the message and sends it the moment the
|
|
1666
|
+
turn ends, instead of doing nothing. One slot: a second send joins the first on a new line, so the model is
|
|
1667
|
+
handed one user message. Staged files ride along, under the same per-message ceilings.
|
|
1668
|
+
- The parked message shows on a card above the composer with two ways out — take it back into the composer to
|
|
1669
|
+
change it, ahead of whatever was typed since, or drop it. The card is the `AgentQueued` slot, with `DefaultQueued`
|
|
1670
|
+
and the `QueuedMessage` type exported beside the other chat parts.
|
|
1671
|
+
- Stop hands a parked message back to the composer rather than opening the next turn with it, so Stop means stop.
|
|
1672
|
+
`/new` drops it along with the conversation, the way it drops staged files. A `/prompt` parks like text; the
|
|
1673
|
+
built-in commands never park, and a pending question still takes the composer as its answer.
|
|
1674
|
+
- A spoken ask parked behind a turn is still the one answered out loud — and a second voice ask no longer re-reads
|
|
1675
|
+
the previous answer while the new one is on its way, which it did whenever the transcript already held one.
|
|
1676
|
+
|
|
1677
|
+
- 69f7178: feat: the agent chat answers slash commands of its own, and the composer remembers what was sent
|
|
1678
|
+
|
|
1679
|
+
**Five commands join the `/` menu the app's `prompt()` endpoints already appear in**: `/new` (`/clear`) starts a
|
|
1680
|
+
new conversation, `/retry` sends the last message again, `/copy` puts the transcript on the clipboard, `/help`
|
|
1681
|
+
lists the commands, and `/tools` lists what this screen published. An app writes none of them and cannot add one —
|
|
1682
|
+
the extension point for a product's own command is a `prompt()` endpoint, which is guarded and server-side.
|
|
1683
|
+
|
|
1684
|
+
**A built-in wins a name collision with a prompt of the same name**, and a shadowed prompt is dropped from the menu
|
|
1685
|
+
rather than listed twice. That is the mirror image of the tool rule, deliberately: a component's `st.tool` shadows a
|
|
1686
|
+
built-in it means to replace, but no library's prompt may take `/new` away from the user who typed it. `/new` and
|
|
1687
|
+
`/copy` are also dispatched ahead of the is-a-turn-running check, because mid-turn is exactly when they are reached
|
|
1688
|
+
for — so `AgentSession.reset` now ends the turn it is clearing and waits for it to wind down. It used to return
|
|
1689
|
+
silently while one was running, and clearing before the abort lands leaves the dying turn appending onto an empty
|
|
1690
|
+
transcript.
|
|
1691
|
+
|
|
1692
|
+
**A command's output is a `local` message: rendered in the transcript, withheld from the wire.** The transcript
|
|
1693
|
+
_is_ the model's history, so `/help` text appended plainly would come back on the next turn as something the
|
|
1694
|
+
assistant believes it said. `session.note(text)` writes one, `session.report(error)` stays what a host-side failure
|
|
1695
|
+
lands in, and `local` messages are left out of a `/copy` export — they are the chat talking to itself.
|
|
1696
|
+
|
|
1697
|
+
**`/copy` exists because nothing else keeps the transcript.** The relay is stateless and the conversation lives only
|
|
1698
|
+
in that browser, so an export is the one path a wrong answer has to whoever could fix it; it carries the route and
|
|
1699
|
+
the timestamp for that reason. **`session.retry()`** replays only the trailing user message and leaves everything
|
|
1700
|
+
before it in place, so a prompt's own preamble is not sent twice.
|
|
1701
|
+
|
|
1702
|
+
**↑ and ↓ in the composer walk what was sent.** A single-line input has nothing of its own on the vertical arrows,
|
|
1703
|
+
and the half-written draft they were walked away from comes back at the bottom of the walk — the other half of why
|
|
1704
|
+
a turn that fails for a reason unrelated to the ask no longer means retyping it.
|
|
1705
|
+
|
|
1706
|
+
- 5f53462: The composer says a file is being read
|
|
1707
|
+
|
|
1708
|
+
`useChatAttachments` now reports how many files are in the reader, and the composer draws a chip for each. The
|
|
1709
|
+
built-in readers resolve in a tick and nothing was missing before; an `attach` that uploads takes seconds, and
|
|
1710
|
+
since the per-file ceiling moved behind the reader that is now the shape an app is meant to write — so a large
|
|
1711
|
+
photo was seconds of a panel that looked like it had dropped the file.
|
|
1712
|
+
|
|
1713
|
+
- f882bbc: feat!: `st.tool`, `st.expose` and `st.useState` become one chain, and a declaration's type is its mask
|
|
1714
|
+
|
|
1715
|
+
**Breaking.** The three declarations an app writes for the in-page agent had three different shapes and two
|
|
1716
|
+
options that did not carry their weight. They are now one shape — `<entry>(name, …) → .desc(text) → <terminal>`,
|
|
1717
|
+
where the terminal call is the hook — and every option left is one the runtime reads. Migration guide:
|
|
1718
|
+
`local/tool-migration.md`.
|
|
1719
|
+
|
|
1720
|
+
```ts
|
|
1721
|
+
st.tool("removeTask", { confirm: true })
|
|
1722
|
+
.desc("Remove one task.")
|
|
1723
|
+
.arg("taskId", ID)
|
|
1724
|
+
.opt("force", Boolean)
|
|
1725
|
+
.exec(fn);
|
|
1726
|
+
st.expose("openNote", cnst.LightNote).desc("The note on screen.").value(note);
|
|
1727
|
+
const [tab, setTab] = st
|
|
1728
|
+
.useState("tab", String, { set: true })
|
|
1729
|
+
.desc("Which tab.")
|
|
1730
|
+
.init("all");
|
|
1731
|
+
```
|
|
1732
|
+
|
|
1733
|
+
**`.desc()` is required, and it is now load-bearing twice.** A model picks a tool by that sentence and by nothing
|
|
1734
|
+
else, so a tool without one was a tool an agent could only guess at. It also replaces `shared: true`: a row
|
|
1735
|
+
component registering fifty `removeTask` is fifty copies of one name _and one description_, which the surface now
|
|
1736
|
+
reads as one declaration, while a second description arriving under a name already taken is two components that
|
|
1737
|
+
collided and still warns. `shared` was an unverified claim whose only effect was suppressing that warning — a flag
|
|
1738
|
+
that said "trust me" where the description already says the same thing, checkably.
|
|
1739
|
+
|
|
1740
|
+
**`effect` becomes `settle`, because only one of its three values ever did anything.** `"query"` skipped the wait
|
|
1741
|
+
for the screen to settle before the call's effect was reported; `"state"` and `"mutation"` were indistinguishable
|
|
1742
|
+
at runtime, were dropped before reaching the model, and coloured a badge. What is left is the fact the session
|
|
1743
|
+
actually needs: `{ settle: false }` is a read that returns what is already there, and everything else is waited
|
|
1744
|
+
out because a write may still be landing when `exec` resolves. `effect` also leaves `PublishedTool`, the relay
|
|
1745
|
+
wire (`AgentWireTool`, `WIRE.md`) and the `/tools` dock badge.
|
|
1746
|
+
|
|
1747
|
+
**`.arg()` is required and `.opt()` is optional**, matching the filter builder's vocabulary, so an optional
|
|
1748
|
+
argument is a different call rather than an options bag — and the `exec` parameter widens to `| null` only for
|
|
1749
|
+
`.opt`.
|
|
1750
|
+
|
|
1751
|
+
**A readable value declares its type, and the type is the mask.** `st.expose(name, Type)` and
|
|
1752
|
+
`st.useState(name, Type)` take a scalar, an enum, a model class, a one-level array of those, or `Any`. The type
|
|
1753
|
+
typechecks what the component hands over — a model resolves to its state object, so a hydrated document and a
|
|
1754
|
+
plain copy of one are equally accepted — and it decides how the value reads: a model strips its own `hidden`,
|
|
1755
|
+
`secret` and `visual` fields by the model that was _named_ rather than by whatever class the value still carries,
|
|
1756
|
+
and a `Date` leaves as an ISO string. That subsumes `mask:` and `serialize:`, both of which are gone; `Any` is the
|
|
1757
|
+
escape hatch and passes the value untouched. A type nothing can read is reported on the console and left
|
|
1758
|
+
unpublished rather than thrown, the same degradation an undescribable `.arg` already had. `.value()` also takes a
|
|
1759
|
+
thunk, read when the agent reads, for a value assembled out of a ref the children fill in after the render.
|
|
1760
|
+
`st.useState`'s `set` is now a boolean, since the write schema comes from the same type.
|
|
1761
|
+
|
|
1762
|
+
- 5f53462: A stopped turn leaves no bubble that is still writing
|
|
1763
|
+
|
|
1764
|
+
`AgentSession` opens an assistant draft before the first token arrives, and Stop caught before that token left the
|
|
1765
|
+
draft in the transcript forever. `Transcript` already drops an empty assistant message, so the wire and the
|
|
1766
|
+
persisted history were both correct and the rendered array was the one place it survived — which is exactly the
|
|
1767
|
+
array a bubble reads to decide it is still being written, so the pulsing dot never stopped. The turn's own settle
|
|
1768
|
+
now drops it, using the predicate `Transcript` already applies. A turn a provider ends without saying anything
|
|
1769
|
+
leaves nothing behind either, for the same reason.
|
|
1770
|
+
|
|
1771
|
+
- 5f53462: An upload control reaches an agent, the way a relation picker already does
|
|
1772
|
+
|
|
1773
|
+
`useFileFieldTool(onChange, { read, label, max, min })` is the counterpart to `useRelationFieldTool` for the other
|
|
1774
|
+
half of _"a relation is picked or uploaded"_: the control hands the files it is holding and gets
|
|
1775
|
+
`load<Field>OptionsOn<Model>` — the name the relation picker already publishes, so one spelling serves a form that
|
|
1776
|
+
draws both — plus the field's own `set<Field>On<Model>`, with `add`/`sub` besides on an array field. Before this an
|
|
1777
|
+
agent could see an attached picture and read the form, and had no way to put one in the other.
|
|
1778
|
+
|
|
1779
|
+
The array pair is not the convenience it is for a list of ids elsewhere. A picker's candidates are the whole
|
|
1780
|
+
resolvable set, so rewriting the array is always expressible; an upload control's are what this conversation
|
|
1781
|
+
brought, and a field that already holds older files cannot be rewritten without ids the guard has never heard of.
|
|
1782
|
+
`max` / `min` are passed rather than derived, since `arrDepth` says a value is a list and not how long a legal one
|
|
1783
|
+
is, and they reach the description as well as the guard — a cap an agent can only learn by tripping the server is
|
|
1784
|
+
the round trip the listing tool exists to spare it. `read` omitted publishes nothing, so a shared control can
|
|
1785
|
+
forward an optional tray without every call site growing a tool that refuses every id.
|
|
1786
|
+
|
|
1787
|
+
- f882bbc: Back a zone's transcript from a mounted `<Agent.History />` instead of a `persist` prop, so the zone can be
|
|
1788
|
+
assembled by a server component.
|
|
1789
|
+
|
|
1790
|
+
A `SessionHistory` is functions, and a function cannot cross the server/client boundary as a prop — so
|
|
1791
|
+
`persist={myStore}` made every ancestor up to whoever builds the session a client component, which for a zone is
|
|
1792
|
+
the whole subtree. `<Agent.History load save clear onCompact />` is a leaf that attaches the store to the
|
|
1793
|
+
enclosing session, in the shape `Agent.Guide` already uses, and renders nothing. `Agent.Zone`'s other props were
|
|
1794
|
+
already serializable, so the zone and the chat inside it can now be server-rendered.
|
|
1795
|
+
|
|
1796
|
+
Restoring follows the rule an async `load` already followed: it lands only while nothing has happened to the
|
|
1797
|
+
conversation yet. Mounting with the zone restores; mounting later saves from there on, and the store is never
|
|
1798
|
+
asked for a transcript that would be discarded. `AgentSession.setHistory` / `setOnCompact` back it, for a session
|
|
1799
|
+
an app built itself.
|
|
1800
|
+
|
|
1801
|
+
`setHistory` and `setOnCompact` return a detach that clears the slot only while their own value is still in it,
|
|
1802
|
+
so a remount's cleanup cannot silently stop the saving of whoever attached after it. The store lives exactly as
|
|
1803
|
+
long as the component; a host that wants it to outlive the view attaches it on the session itself.
|
|
1804
|
+
|
|
1805
|
+
Also documents that `open` without `onOpenChange` draws no close button — a fixed panel with nowhere to close to,
|
|
1806
|
+
and the shape that keeps a controlled chat free of function props.
|
|
1807
|
+
|
|
1808
|
+
- f882bbc: Hide `Agent.Dock` and `Agent.Context` in production.
|
|
1809
|
+
|
|
1810
|
+
Both are a developer inspector — they list every published tool, can run one by hand, and preview the turn snapshot. A layout that mounts them would have shown that to every visitor on `AKAN_PUBLIC_ENV=main`. They still render on local, debug, develop, and testing.
|
|
1811
|
+
|
|
1812
|
+
- 5f53462: feat: the composer draws a pointer as the name it points at
|
|
1813
|
+
|
|
1814
|
+
Picking a row off the `@` menu used to leave `@[이번엔, 진짜입니다](mention:videoCut/6aae…)` sitting in the
|
|
1815
|
+
composer — the token is what carries the reference onto the message, so it had to be in the text, and the text is
|
|
1816
|
+
what a textarea draws. It is now drawn by a Lexical editor instead, where each pointer is one atomic node whose
|
|
1817
|
+
label is what the person reads and whose `getTextContent()` is the token. The draft string the chat reasons about
|
|
1818
|
+
is unchanged, pointers and all: `Reference.parse`, the `@` query, the chips, the wire and the server's own framing
|
|
1819
|
+
all see exactly what they saw before, and the editor is one way of drawing it.
|
|
1820
|
+
|
|
1821
|
+
A pointer deletes in one backspace rather than a character of a label that would then name nothing, and every
|
|
1822
|
+
offset the chat hands over — the caret a `session.refer` inserts at, the line a recall arrow belongs to — stays an
|
|
1823
|
+
offset into that string.
|
|
1824
|
+
|
|
1825
|
+
It costs nothing where it is not used: the editor is its own chunk behind the chat's own, and `Agent.Chat` only
|
|
1826
|
+
draws it where `reference` sources were declared. `mentions={false}` keeps the plain textarea, for an app that
|
|
1827
|
+
overrides the composer or would rather see the tokens it is sending.
|
|
1828
|
+
|
|
1829
|
+
Lexical rather than a contenteditable of our own, because a Korean or Japanese IME composing into a contenteditable
|
|
1830
|
+
that React also re-renders is the bug class the library exists to own.
|
|
1831
|
+
|
|
1832
|
+
- 69f7178: feat: derive an agent surface from the store, annotate the DOM with it, and add a read-only insight query
|
|
1833
|
+
|
|
1834
|
+
The server half of agent support is the signal registry; this is the client half plus the layer-bypassing read. All
|
|
1835
|
+
of it is derived — an app writes no declaration for any of it.
|
|
1836
|
+
|
|
1837
|
+
**`SerializedStore` and `StoreCatalogue`** describe one built store the way `SerializedSignal` describes a signal
|
|
1838
|
+
registry: every key on `st.use` and every action on `st.do`, with argument schemas. Flat rather than grouped by
|
|
1839
|
+
model, because that is what the store is — one namespace, where a key means the same thing to every reader.
|
|
1840
|
+
|
|
1841
|
+
The exposure default is the opposite of the MCP catalogue's, and deliberately: a key on `st.do` is the same call the
|
|
1842
|
+
user's own click makes, under their own credential, with them watching, so an agent driving it cannot reach past what
|
|
1843
|
+
the UI already permits. Every key is published unless something about it cannot be described, and each of those is
|
|
1844
|
+
recorded as a refusal with the reason — a `hidden`/`secret` field setter (the masking boundary facing the other way),
|
|
1845
|
+
a `Map` or relation field, a `FileList` upload, `selectModel` (it stores the list item it was handed, so an id would
|
|
1846
|
+
leave a stub), and any action that declares arguments no endpoint or field describes.
|
|
1847
|
+
|
|
1848
|
+
Nothing re-derives a name rule. An action's arguments come from the endpoint it is named after — which is not luck
|
|
1849
|
+
but the house rule that makes `st.do.X` read the same as `fetch.X` — or from the field metadata the setter was
|
|
1850
|
+
generated from, or from the role the store recorded while it built the slice. Two things had to be added to make that
|
|
1851
|
+
possible: arity is captured in `#mergeActions` (the `st.do` wrapper takes rest arguments, so `Function.length` is
|
|
1852
|
+
zero for everything by the time anyone can ask), and `ACTION_OWNER_META` records which module declared each action,
|
|
1853
|
+
which is what names the dictionary node its words are read from.
|
|
1854
|
+
|
|
1855
|
+
**`AgentBridge`** turns that into tools with JSON schemas, checks each argument against what it declared, dispatches
|
|
1856
|
+
through `st.do`, masks reads, and keeps a transcript. It holds no model, provider, or key — an app wires whichever
|
|
1857
|
+
agent it uses to `tools` / `call` / `read`. `Agent.Dock` in `akanjs/ui` renders the catalogue, the refusals and the
|
|
1858
|
+
transcript, and can run a tool by hand; it is how you see what a page actually publishes.
|
|
1859
|
+
|
|
1860
|
+
Reads are masked because they have to be: `<model>Form` holds what the user just typed, credentials included, and an
|
|
1861
|
+
in-page agent ships what it reads to a remote model. The mask is by the declared model rather than by the value's
|
|
1862
|
+
class, since `immerify` copies a form into a plain object and the class is gone by then. `Msg.mask` moved to
|
|
1863
|
+
`constant/mask.ts` so both audiences use one implementation.
|
|
1864
|
+
|
|
1865
|
+
**`data-akan-action` / `data-akan-state`** are emitted by the framework's own interactive primitives, with no app
|
|
1866
|
+
code at all. `onChange={st.do.setNameOnUser}` — the house form for every model field — already carries everything an
|
|
1867
|
+
annotation needs; it just had no way to be read off a function, and now it does. `Input` (every variant plus
|
|
1868
|
+
`Checkbox`), `Select`, `Switch`, `Button`, and the `Field.*` fields all carry it. An inline arrow gets nothing,
|
|
1869
|
+
because a closure the caller wrote says nothing about what it does. Accessibility trees, E2E selectors, and external
|
|
1870
|
+
browser agents get the same names for free.
|
|
1871
|
+
|
|
1872
|
+
**`InsightQuery`** is one read-only SQL statement, for a question the domain endpoints cannot express. Read-only is
|
|
1873
|
+
enforced four ways — the statement is wrapped as a derived table, where nothing but a query is legal in either
|
|
1874
|
+
dialect; a pre-execution check runs on the statement with comments and string literals removed; forbidden keywords
|
|
1875
|
+
are rejected rather than trusting that Postgres refuses a data-modifying CTE inside a subquery; and the `_doc` column
|
|
1876
|
+
never crosses the boundary, since that is where every maskable field lives and an arbitrary SELECT names no model to
|
|
1877
|
+
mask by. Rows are capped at 1000, which no caller can raise. The timeout bounds the wait, not the query —
|
|
1878
|
+
`bun:sqlite` is synchronous and holds the loop, so the ceiling is what limits that case.
|
|
1879
|
+
|
|
1880
|
+
What the insight query costs is worth stating: it answers "how many, since when, grouped how" over base columns and
|
|
1881
|
+
the search mirror, and it cannot read a domain field. Field-level reads go through the domain tools, which mask.
|
|
1882
|
+
|
|
1883
|
+
`libs/shared` exposes it as `runAdminSql` on the admin signal, guarded by `SuperAdmin` and returning the new
|
|
1884
|
+
`insightRows` scalar. It is a `mutation` despite not being able to write, because a `query` is a GET that never sends
|
|
1885
|
+
a body — the statement would have to travel in the URL, where it lands in every access log and hits the length limit
|
|
1886
|
+
— and because a query's response is memoized per URL, which is wrong for an ad-hoc statement. It is not MCP-exposed:
|
|
1887
|
+
the catalogue is built once at boot rather than per caller, so publishing it would tell every client that can reach
|
|
1888
|
+
`/mcp` that an arbitrary-SQL tool exists.
|
|
1889
|
+
|
|
1890
|
+
- 5f53462: `readScreen` names every image, and can carry its address
|
|
1891
|
+
|
|
1892
|
+
An `<img>` with no `alt` contributed nothing at all, so a card rendering a picture read exactly like a card
|
|
1893
|
+
rendering nothing — and "this page shows no image" is the one answer the screen could not support. Every image now
|
|
1894
|
+
stands as `[image: <alt>]` or `[image]`, and `readScreen({ images: true })` appends `(<src>)` for handing one to a
|
|
1895
|
+
tool that takes a picture. It is off by default because a gallery is one long URL per thumbnail, and a `data:` URL
|
|
1896
|
+
is never printed: that is the bytes themselves rather than somewhere to fetch them.
|
|
1897
|
+
|
|
1898
|
+
- 69f7178: fix: refuse `runAgentTurn` until the app names an `AgentRelayAccess` guard
|
|
1899
|
+
|
|
1900
|
+
The relay used to allow every caller and warn at boot until an app decided. With no guard it now answers like
|
|
1901
|
+
`None` — `Access denied by guard: AgentRelayAccess` — and boot is silent. Name one with
|
|
1902
|
+
`option.setAgentAccess(...)` (or `AgentRelayAccess.use`) before the chat can spend the LLM key.
|
|
1903
|
+
|
|
1904
|
+
- f882bbc: Open the in-page agent's session to the host: a custom transcript store, a narrowed built-in tool set, and the
|
|
1905
|
+
types a replacement transport needs.
|
|
1906
|
+
|
|
1907
|
+
- `persist` now takes a `SessionHistory` (`{ load, save, clear }`) as well as the web-storage option, and all
|
|
1908
|
+
three methods may answer asynchronously — a transcript can live on a server. A `load` still in flight when the
|
|
1909
|
+
user sends the first message is dropped rather than merged, and saves are chained so a slow store cannot land
|
|
1910
|
+
an older transcript last. `session.isRestoring` reports the in-flight load — a host that opens with a prompt of
|
|
1911
|
+
its own waits for it, since sending on mount is a race the restore loses.
|
|
1912
|
+
- `builtins` on `Agent.Chat` and `Agent.Zone` picks which of the runtime's own tools a session gets (`false` for
|
|
1913
|
+
none, an array for exactly those). A withheld name answers the same "unknown tool" an unregistered one does.
|
|
1914
|
+
This is the only way to drop `navigate` inside a zone, where a same-named hook tool is registered under its
|
|
1915
|
+
scope prefix and never shadows the built-in.
|
|
1916
|
+
- `Agent.Zone` takes a `session` the app built (and then does not abort it on unmount) and reports it through
|
|
1917
|
+
`onSession`. `Agent.Chat` takes `chrome={false}` to drop the header bar and `defaultDraft` to open with text
|
|
1918
|
+
in the composer; a panel controlled without an `onOpenChange` draws no close button instead of an inert one.
|
|
1919
|
+
- `onCompact(replaced, summary)` reports a compaction's cut, for a host keeping its own summary watermark.
|
|
1920
|
+
- `akanjs/ui` now exports `httpRunner`, `fetchRunner`, `AgentSession`, `AgentProvider` and the `AgentRunner` /
|
|
1921
|
+
`RunnerRequest` / `RunnerEvent` / `ChatMessage` / `SessionHistory` / `PublishedTool` / `ContextBlock` types.
|
|
1922
|
+
|
|
1923
|
+
- 5f53462: Two providers that read a picture ship, and `accepts` is answered per model
|
|
1924
|
+
|
|
1925
|
+
Everything the framework carries for an attachment ended at a text-only provider: one chat-completions
|
|
1926
|
+
`LlmAdaptor` shipped and declared no `accepts`, so an image reached the model as a note saying it could not
|
|
1927
|
+
be read, and any app wanting vision wrote the provider mapping itself — role alternation, tool-call placement,
|
|
1928
|
+
streaming reassembly, SSE framing, none of it app-specific work.
|
|
1929
|
+
|
|
1930
|
+
`OpenaiLlm` speaks the chat-completions dialect against `https://api.openai.com/v1` and declares `{ image: true }`,
|
|
1931
|
+
sending an image as a content part from either carrier. `AnthropicLlm` is the Messages API and declares
|
|
1932
|
+
`{ image: true, document: true }`. That dialect now lives in `OpenaiDialect`, so a
|
|
1933
|
+
protocol fix lands once; Anthropic shares nothing but the wire it maps from, its system prompt being a field
|
|
1934
|
+
rather than a message, its tool calls and results content blocks, its results a _user_ turn, its roles strictly
|
|
1935
|
+
alternating, and its `max_tokens` required. Both new adaptors require `model`: a default would age into a 404 and
|
|
1936
|
+
would decide the vision claim on the app's behalf.
|
|
1937
|
+
|
|
1938
|
+
`option.setLlm({ accepts })` overrides what the configured model reads. An adaptor answers for an API and one API
|
|
1939
|
+
serves models that differ, so the answer rides beside the `model` it is a fact about rather than in a capability
|
|
1940
|
+
table the framework keeps — a table is a claim about models that ship after it, and a provider handed bytes it
|
|
1941
|
+
cannot decode either refuses the turn or accepts it having seen nothing.
|
|
1942
|
+
|
|
1943
|
+
`option.setLlm({ maxTokens })` is the answer ceiling for an API that requires one. A fixed default is a hazard on
|
|
1944
|
+
a model that reasons before it writes: the budget goes on thinking, the turn comes back empty with a length stop,
|
|
1945
|
+
and that reads as the model refusing — so an empty answer is also named in the log with the current number.
|
|
1946
|
+
Sampling knobs are deliberately absent from `LlmOption`, because `temperature` is a 400 rather than an ignored
|
|
1947
|
+
field on some models and the role would have to guess its legality per model.
|
|
1948
|
+
|
|
1949
|
+
A mangled SSE frame now costs that frame instead of the whole answer, in both stream readers: throwing lost the
|
|
1950
|
+
text already streamed over one unreadable line of a protocol the caller cannot fix.
|
|
1951
|
+
|
|
1952
|
+
Both match an exact image-type set (`jpeg`, `png`, `gif`, `webp`) rather than an `image/*` prefix, and name the
|
|
1953
|
+
rest in the text. `accepts.image` is one boolean, so `AgentService.readable` passes every `image/*` through and the
|
|
1954
|
+
composer's built-in reader base64s every `image/*` — an `AttachReader` answering `null` means "not mine" and falls
|
|
1955
|
+
through to it — so an app cannot gate one either. An unsupported type is not one unread attachment: it is a block
|
|
1956
|
+
the API refuses, so the whole turn dies on a vendor 400, and `image/heic` is the iPhone camera default.
|
|
1957
|
+
|
|
1958
|
+
`OpenaiLlm` is the default. Swap with `option.applyAdaptor(LlmAdaptorRole, AnthropicLlm)`.
|
|
1959
|
+
|
|
1960
|
+
- 5f53462: An attached image shows up, and survives a reload with its handle
|
|
1961
|
+
|
|
1962
|
+
The attachment chip only drew a thumbnail for an inlined `data` attachment, so an image attached by `url` — the
|
|
1963
|
+
shape the per-file ceiling exists to encourage, and the only shape a deployed app produces — rendered as a bare
|
|
1964
|
+
name in the composer and in every bubble. It draws either carrier now.
|
|
1965
|
+
|
|
1966
|
+
`sessionHistory` dropped `ref` on the way to storage. The reason content is stripped is that web storage is a few
|
|
1967
|
+
megabytes and a failed save is silent; an id is not content, and it is the one thing a restored conversation has
|
|
1968
|
+
left to find the file again — without it a host is back to guessing from name and size, which is the guess `ref`
|
|
1969
|
+
was added to retire.
|
|
1970
|
+
|
|
1971
|
+
`Chips` is exported as `AgentAttachments`, so a replaced `AgentBubble` draws the attachment row with the framework's
|
|
1972
|
+
rule rather than a copy of it. And a compacted attachment now says its content is gone instead of only naming
|
|
1973
|
+
itself, on the same principle as every other note here: a model told only `[attached photo.png]` answers about the
|
|
1974
|
+
picture from its filename.
|
|
1975
|
+
|
|
1976
|
+
- 69f7178: feat: akanjs/ui drives itself for an in-page agent
|
|
1977
|
+
|
|
1978
|
+
The framework's own components now publish the controls they draw, so an app gets a working agent surface for
|
|
1979
|
+
list management, tabs, paging, forms, dialogs, and the app shell without writing a line. Names are the store
|
|
1980
|
+
action's wherever one exists (`setSortOfTaskInOrg`, `removeTask`), so the tool an agent calls and the action the
|
|
1981
|
+
button dispatches read the same.
|
|
1982
|
+
|
|
1983
|
+
- **`Data.ListContainer`**, and so every `Model.AdminPanel`: view mode, sort, page size, refresh, create, the two
|
|
1984
|
+
exports, and the row and modal verbs — `edit`/`view`/`remove` by id, plus `submit`, `cancelEditOf`,
|
|
1985
|
+
`closeViewOf`. It also opens a `<slice>.items` resource, which is where the id for a row verb comes from.
|
|
1986
|
+
- **`Tab`**, **`Dialog`**, **`ScreenNavigator`** take a `namespace` prop and publish under it. Without one they
|
|
1987
|
+
publish nothing: two tabs on one screen would otherwise answer to a single name, and the first to mount would
|
|
1988
|
+
lose. `Tab`'s menu registry became a `Map` of menu to disabled, which also stopped the disabled-tab fallback
|
|
1989
|
+
from landing on another disabled tab.
|
|
1990
|
+
- **Paging** is one shared `usePageTool`, spoken by all three components that draw a pager.
|
|
1991
|
+
- **`Layout.Sider`**, **`System.SelectLanguage`**, **`Link.Back`** publish the shell controls.
|
|
1992
|
+
|
|
1993
|
+
**Forms fill themselves, from two sides.** An app writes no `st.tool` for a form.
|
|
1994
|
+
|
|
1995
|
+
A form control handed its setter **by reference** — `onChange={st.do.setTitleOnTask}` — publishes
|
|
1996
|
+
`setTitleOnTask` while it is on screen. That is the same reference that already earned `data-akan-action`, so the
|
|
1997
|
+
agent's tool and the person's control are one function, and an inline arrow still publishes nothing. Scoping
|
|
1998
|
+
publication to the control rather than to the model is what keeps an agent out of a field the template draws
|
|
1999
|
+
nothing for.
|
|
2000
|
+
|
|
2001
|
+
`st.use.taskForm()` adds one more: `fillTaskForm(patch)`. It takes several fields in a single call, and it is the
|
|
2002
|
+
only way to reach a list, a map, or an embedded object — their rows are written through
|
|
2003
|
+
`writeOnTask("payments.3.name", value)`, an inline call that can carry no annotation. It is a patch, so a field
|
|
2004
|
+
left out keeps what the person typed. Its schema is every writable field, because a declaration is mount-static;
|
|
2005
|
+
the **guard** is where the screen gets its say, refusing a plain field whose control is not on screen and naming
|
|
2006
|
+
the ones that are. A composite is let through, because nothing can see whether its rows rendered — the one place
|
|
2007
|
+
left where an agent reaches a field the screen may not draw, and server guards still apply.
|
|
2008
|
+
|
|
2009
|
+
Neither side touches a relation (picked or uploaded, never typed), a base document field, or a `hidden`/`secret`
|
|
2010
|
+
one **at any depth**: a read of those is masked, so publishing a writer would open the door its reader is barred
|
|
2011
|
+
from. A rejected third shape is worth naming — a single `writeOnTask(path, value)` tool — because its `value`
|
|
2012
|
+
could only be `Any`, which this framework's own MCP layer refuses for telling a model nothing, and a mistyped
|
|
2013
|
+
`path` writes a new key into the form that ships on the next submit.
|
|
2014
|
+
|
|
2015
|
+
Nothing publishes a lever the screen does not have. A model with fewer than two sort keys draws no sort control
|
|
2016
|
+
and has no `setSortOf<Model>`; a panel with no template draws no create button and has no `new<Model>`; a list
|
|
2017
|
+
that fits on one page has no pager and no `setPageOf<Model>`. Callables go to the controls by reference, so
|
|
2018
|
+
`data-akan-action` lands on them and `readScreen` names each control with the word its tool has.
|
|
2019
|
+
|
|
2020
|
+
Three gaps in the surface had to close first, all of them consequences of the surface being declaration-only.
|
|
2021
|
+
|
|
2022
|
+
**A conditional surface had no legal shape.** `.exec()` is a hook, so a component can never skip the declaration —
|
|
2023
|
+
which left no way to publish a tool only when the screen renders its control. A falsy name now declares the tool
|
|
2024
|
+
and publishes nothing: the callable still drives the click a person makes, and the agent never learns it exists.
|
|
2025
|
+
`st.useState` and `st.expose` take a falsy name the same way. An unpublished callable carries no
|
|
2026
|
+
`data-akan-action`, because that attribute names a tool an agent can reach and this one cannot. This is what lets
|
|
2027
|
+
a list toolbar publish `setSortOfTask` only when it actually draws the sort control, instead of paying for the
|
|
2028
|
+
tool in every turn's prompt on every screen.
|
|
2029
|
+
|
|
2030
|
+
**A value set only the render knows had no way in.** An `enumOf` class was already a complete argument type —
|
|
2031
|
+
`.arg("mode", TaskStatus)` publishes the values, refuses anything off them, and narrows the `.exec` parameter to
|
|
2032
|
+
the union — but a component cannot build one, because `enumOf` registers globally. A slice's sort keys or the
|
|
2033
|
+
options a prop carried could only be described in prose and hoped for. `.arg(name, type, { oneOf })` takes the
|
|
2034
|
+
list the render has and publishes and enforces it the same way. Neither reaches a set that fills in _after_ the
|
|
2035
|
+
first render, since a declaration is mount-static; that belongs in the tool's `guard`, which is re-read per call
|
|
2036
|
+
and can name the current values in its refusal — which is what `Tab` and the pagers do.
|
|
2037
|
+
|
|
2038
|
+
**A list inside an `Agent.Zone` was invisible to that zone's own agent.** `useScreenScope` opened its scope at the
|
|
2039
|
+
root rather than under the scope it is mounted in, so `Load.Units` inside a zone registered `<slice>.items` at the
|
|
2040
|
+
top. A zone view only sees keys in its own subtree, so the root agent could read the list and the zone agent
|
|
2041
|
+
looking straight at it could not.
|
|
2042
|
+
|
|
2043
|
+
- 69f7178: fix: an undescribable `st.tool` argument withdraws the tool instead of throwing during render
|
|
2044
|
+
|
|
2045
|
+
`.arg(name, type)` validated the type where it was written and threw for anything that was not a scalar or an
|
|
2046
|
+
enum. That check runs during render, so a model class passed to one argument of one component tool aborted the
|
|
2047
|
+
server render of the whole route — React fell back to client rendering with `st.tool takes scalar and enum
|
|
2048
|
+
arguments only.`, a message naming neither the tool, nor the argument, nor the type.
|
|
2049
|
+
|
|
2050
|
+
It now withdraws the tool the way a falsy name does — nothing is published, the callable still drives the click a
|
|
2051
|
+
person makes, and the page renders — and reports it as
|
|
2052
|
+
`st.tool("editProject") is not published: its "info" argument is the type PortfolioInfo, and st.tool takes scalar
|
|
2053
|
+
and enum arguments only.` `st.useState`'s `set` degrades to read-only on the same terms. This matches what the
|
|
2054
|
+
surface already did everywhere else: `FormFields` drops a field it cannot describe, and MCP refuses an endpoint
|
|
2055
|
+
and names it in the boot log. An agent-tooling concern should not be able to cost a route its server rendering.
|
|
2056
|
+
|
|
2057
|
+
Note that form fields were never able to trigger this: `useFormTools` and `useFieldTool` build their schemas from
|
|
2058
|
+
an effect, which does not run during SSR. A regression test now server-renders a form over a model carrying an
|
|
2059
|
+
embedded-scalar array field to keep it that way.
|
|
2060
|
+
|
|
2061
|
+
- 5f53462: `st.tool` describes one array level of a scalar or an enum
|
|
2062
|
+
|
|
2063
|
+
`.arg("bodies", [String])` and `.opt("modes", [TaskStatus])` publish `{ type: "array", items }` and check every
|
|
2064
|
+
element, with `oneOf` narrowing the elements rather than the list. It is the schema `fill<Model>Form` already
|
|
2065
|
+
built for an array field, so the same shape reaching an agent through a form and not through a component tool was
|
|
2066
|
+
an oversight — a tool that could not say "a list of these" taught the format in prose, and the app then owned a
|
|
2067
|
+
parser for it.
|
|
2068
|
+
|
|
2069
|
+
- 69f7178: fix!: publish only what a component declared to the in-page agent
|
|
2070
|
+
|
|
2071
|
+
This supersedes the store-derived in-page surface described in the earlier entries of this release: the store
|
|
2072
|
+
catalogue keeps its state half, and its action half is gone.
|
|
2073
|
+
|
|
2074
|
+
An in-page agent stands in for the person looking at the screen, so what it may do is what that screen offers them
|
|
2075
|
+
and nothing else. Deriving its tools from the store missed that by one level: liveness was per _store_, so a single
|
|
2076
|
+
`st.use.me()` in a gating component published every method the admin module had ever declared — `setAdminPassword`
|
|
2077
|
+
among them — and `readState` reached every key of that store rather than the keys the screen reads. Levers the
|
|
2078
|
+
screen does not have are not reach, they are noise the model pays for on every turn.
|
|
2079
|
+
|
|
2080
|
+
**`st.tool` is the only way an action reaches an agent.** The store catalogue's whole action half is gone: endpoint
|
|
2081
|
+
argument borrowing, generated form setters, slice actions, no-argument custom actions, the arity check against the
|
|
2082
|
+
same-named endpoint, the JSON-schema build, the argument checking, `AgentBridge.call`, and its transcript. A store
|
|
2083
|
+
method reaches an agent by being wired into one — `.exec((id) => st.do.removeX(id))` — which is also the shape that
|
|
2084
|
+
hands the same callable to `onClick`, so the person and the agent press one handler. `remove*` still confirms by
|
|
2085
|
+
default; the gate moved to `StToolBuilder` with everything else.
|
|
2086
|
+
|
|
2087
|
+
**Reading is per key, not per store.** `AgentBridge.read` gates on the key being subscribed in that view, so a
|
|
2088
|
+
component reading `userList` says the screen shows a list and says nothing about the `userForm` beside it in the
|
|
2089
|
+
same store. The catalogue keeps its state half, because a read has to be masked and only the store declares which
|
|
2090
|
+
model masks it.
|
|
2091
|
+
|
|
2092
|
+
**`static agent` is gone.** It existed to trim an exposure nothing derives any more, so a store class now says
|
|
2093
|
+
nothing about agents at all — which is the same rule as the tools. `st.use.x({ agent: false })` is how the
|
|
2094
|
+
component that subscribes a value keeps it off the surface, and it is the component's call because the component
|
|
2095
|
+
is what put the value on screen. Delete any `static agent` declaration; there is no replacement to write.
|
|
2096
|
+
|
|
2097
|
+
Base-store plumbing follows the same rule as any other key: `st.use.path({ agent: false })` and
|
|
2098
|
+
`st.use.tryJwt({ agent: false })` keep routing and the caller's credential off the surface at the call site. The
|
|
2099
|
+
route block still carries pathname, params, and searchParams. A screen that wants an agent to read a base key
|
|
2100
|
+
opts it in — ThemeToggle subscribes `theme` without the opt-out.
|
|
2101
|
+
|
|
2102
|
+
**`Agent.Dock` reads the surface** rather than the store, so its tool list is what the page declared, and
|
|
2103
|
+
`AgenticSurface` keeps the last 200 calls so the transcript now covers every tool instead of the store's own.
|
|
2104
|
+
|
|
2105
|
+
- 5f53462: A turn the provider cut off says so, instead of passing as a finished one
|
|
2106
|
+
|
|
2107
|
+
`LlmTurnAnswer.stop` gains `"length"` — `finish_reason: "length"`, `stop_reason: "max_tokens"` — and it rides the
|
|
2108
|
+
wire and the `AgentStop` enum to the browser. It is not one provider's quirk: every adaptor here reports it,
|
|
2109
|
+
including the default, and only the shape of the union was hiding that.
|
|
2110
|
+
|
|
2111
|
+
Without it a truncated answer arrived as `stop: "end"` and the user read half a sentence as the whole reply. Worse
|
|
2112
|
+
on an agent surface: a turn cut off mid tool call carries no complete call, so it ended the loop looking exactly
|
|
2113
|
+
like a model that had decided it was done — a hang the transcript explained as a choice. The session now records
|
|
2114
|
+
the turn as incomplete on the assistant message the user is reading, and closes any call the turn did make rather
|
|
2115
|
+
than running it, since a batch the model was interrupted inside is half an intention.
|
|
2116
|
+
|
|
2117
|
+
The ceiling wins over the calls that did arrive, in both adaptors and on both the streamed and whole paths.
|
|
2118
|
+
|
|
2119
|
+
- f882bbc: fix: `st.expose` / `st.useState` typed `String` or `Boolean` resolve to the scalar, not to a model state object
|
|
2120
|
+
|
|
2121
|
+
`AgentValueOf` matched a declaration's `FIELD_META` before its `CLIENT_VALUE`, and `via.ts` augments the global
|
|
2122
|
+
`String`, `Boolean`, `Date` and `Map` constructors with `DatabaseConstantStatics` — which carries `FIELD_META`. So
|
|
2123
|
+
those constructors read as model classes: `st.useState("tab", String, { set: true }).desc("…").init("all")` handed
|
|
2124
|
+
back `GetStateObject<String>` instead of `string`, and the same for `Boolean`. `Date` escaped only because it is
|
|
2125
|
+
special-cased above, and `Int` / `Float` / `ID` / `Any` are declared classes rather than augmented globals.
|
|
2126
|
+
|
|
2127
|
+
Only the value `st.useState` returns was wrong. `.value()` on `st.expose` kept accepting the literal, because a
|
|
2128
|
+
string is assignable to `GetStateObject<String>` — which is exactly why nothing caught it: the runtime, the
|
|
2129
|
+
schemas, and every `st.expose` call site were correct throughout.
|
|
2130
|
+
|
|
2131
|
+
A scalar is now matched by `refName`, above the model branch. A model carries no `refName`, so nothing about the
|
|
2132
|
+
model path changes — a model still resolves to its state object and still takes a hydrated document or a plain
|
|
2133
|
+
copy of one. `AgentValueOf` is pinned branch by branch in `AgentValue.test.ts`, and `StStateBuilder.test.tsx`
|
|
2134
|
+
covers a `String` and a `Boolean` state end to end.
|
|
2135
|
+
|
|
2136
|
+
Anyone on `3.0.0-alpha.53` who worked around this with `as unknown as string` / `as unknown as boolean` on an
|
|
2137
|
+
`st.useState` result can delete those casts.
|
|
2138
|
+
|
|
2139
|
+
- 69f7178: feat: the in-page agent waits for the screen, aims its reads, points at things, and says what it is doing
|
|
2140
|
+
|
|
2141
|
+
**A tool that changes the screen now waits for the screen before it answers.** `router.push` returns while the RSC
|
|
2142
|
+
payload for the new route is still in flight, so `navigate` used to report success onto a page that had not
|
|
2143
|
+
rendered yet: the `readScreen` in the same turn read the page the user had just left, and the new route's tools were
|
|
2144
|
+
not registered. `ScreenSettle.wait()` waits for DOM quiescence — bounded, and quiescence rather than a framework
|
|
2145
|
+
signal because the client router hands its promise to nobody and a change may land in the store, in a refetch, or
|
|
2146
|
+
in a streamed Suspense boundary. `navigate` awaits it, and `AgentSession` awaits it after every non-`query` tool
|
|
2147
|
+
before taking the change report, so an optimistic action that commits a tick later is reported as what it did
|
|
2148
|
+
rather than as the moment before it. Tools and state from a fresh route are still only listed from the next turn —
|
|
2149
|
+
the catalogue is snapshotted when a turn starts — and the navigate result says so.
|
|
2150
|
+
|
|
2151
|
+
**`goBack` joins `navigate` as a global built-in.** It was briefly declared by `Link.Back`, which made it exist
|
|
2152
|
+
only where a back link happened to be rendered — but history is not a control a page owns: every route has a
|
|
2153
|
+
previous page, the browser's own gesture is always there, and a page that draws no back link is not a page you may
|
|
2154
|
+
not leave. It refuses at call time when nothing is behind the current page instead of walking out of the app.
|
|
2155
|
+
|
|
2156
|
+
**`readScreen` takes a `section` and there is a new `highlight(target)`.** Both resolve a name the agent has
|
|
2157
|
+
already seen rather than a selector it invented: a `data-akan-action` / `data-akan-state` annotation (the one
|
|
2158
|
+
`readScreen` prints beside a control), an `Agent.Zone` or `useScreenScope` container, an element id, or a heading
|
|
2159
|
+
by its own text — matched on letters and digits, so the slug an agent writes for a heading it read resolves. That
|
|
2160
|
+
tolerance stops at headings: a heading is a landmark and scrolling to the wrong one costs nothing, while two
|
|
2161
|
+
buttons reading "Save" are not the same control. Nothing hidden resolves at all, because a ring nobody can see
|
|
2162
|
+
reads as a broken tool rather than as a miss. A name that resolves to nothing is the caller's mistake, with a
|
|
2163
|
+
refusal that lists the sections actually on screen.
|
|
2164
|
+
|
|
2165
|
+
**A screen is only aimable if its names are printed**, which is what the first version got wrong: a page of twenty
|
|
2166
|
+
`Scroll.Slide` sections answered "nothing on this screen carries a name", because `readScreen` printed the heading
|
|
2167
|
+
text without its anchor and the truncation note stopped at a character count. Headings now carry `(#anchor)` when
|
|
2168
|
+
they open an id'd or scoped container, and a truncated read ends with the headings below the cut — otherwise
|
|
2169
|
+
everything past the 8000-character limit is unreachable, since nothing names it. `highlight` flashes **after the
|
|
2170
|
+
scroll lands**: a smooth scroll across a long page outlasts a flash begun at the top, which is the same bug wearing
|
|
2171
|
+
a different hat. `useScreenScope` now hands
|
|
2172
|
+
back its scope path and `Load.Units` / `Load.View` / `Data.ListContainer` put it on the container they render, so
|
|
2173
|
+
every list and detail view on an akan screen is addressable with no app code. `highlight` scrolls its target into
|
|
2174
|
+
view and flashes it on the app's own primary token: it is the one built-in that exists for the _user's_ benefit,
|
|
2175
|
+
because showing where a control is beats writing directions to it.
|
|
2176
|
+
|
|
2177
|
+
**A slow tool can say what it is doing.** `AgentProgress.report(message, { done, total })` is the browser twin of
|
|
2178
|
+
`McpProgress.report` — reached through a module slot rather than a parameter, so work several frames down (a store
|
|
2179
|
+
action, an upload loop, an adapter) reports without every signature growing a channel argument, and a no-op when
|
|
2180
|
+
nobody is rendering it. The chat shows the report on that call's row until the row resolves. A session runs tool
|
|
2181
|
+
calls one at a time, which is why the browser needs no `AsyncLocalStorage` to do this.
|
|
2182
|
+
|
|
2183
|
+
**The turn cap is a question instead of a dead end.** At `maxTurns` the session asks whether to keep going through
|
|
2184
|
+
the same card `askUser` uses, and the answer rides as the user's own turn — so a steer typed instead of the
|
|
2185
|
+
keep-going choice reaches the model as guidance rather than being swallowed. A host that renders no
|
|
2186
|
+
`pendingQuestion` passes no `continueAsk` and keeps the old failure, because asking with nobody listening would
|
|
2187
|
+
hang.
|
|
2188
|
+
|
|
2189
|
+
- 69f7178: feat: the in-page agent waits out long work instead of polling it, and Stop reaches a tool that is still running
|
|
2190
|
+
|
|
2191
|
+
**An agent that started something slow had one way to learn it finished: ask again.** Every look is a full model
|
|
2192
|
+
round trip, so a two-minute video generation burned the default eight-turn budget in about twenty seconds and read
|
|
2193
|
+
to the user as check → wait → check → wait, forever. Two things change.
|
|
2194
|
+
|
|
2195
|
+
**A tool that awaits its own work now holds the turn, with no model round trip in between.** This always worked —
|
|
2196
|
+
the session awaits `#execute` and `AgenticSurface.call` awaits `entry.run` — but nothing said so and nothing made
|
|
2197
|
+
it safe, so apps wrote fire-and-forget tools and left the agent to poll. The shape is one `st.tool` whose `.exec`
|
|
2198
|
+
awaits the store action that finishes the job; the change report that follows carries whatever landed while it
|
|
2199
|
+
waited, so the model needs no second call to read the result.
|
|
2200
|
+
|
|
2201
|
+
**Stop now ends a turn parked inside a tool.** `AgentSession` passed its abort signal to the approval and question
|
|
2202
|
+
cards and nowhere else, so a call that took two minutes held the loop for two minutes after the user pressed Stop,
|
|
2203
|
+
with the chat still showing a turn in flight. Latent until now, and a certainty the moment a tool is allowed to
|
|
2204
|
+
wait. The session races every call against the signal, and hands the signal to the tool through `AgentAbort` — the
|
|
2205
|
+
module slot `AgentProgress` already is, for the same reason: work several frames down reads it without every
|
|
2206
|
+
signature between here and there growing an argument, and a session runs tool calls one at a time. Honouring it is
|
|
2207
|
+
optional, since the race lands whatever the tool does; what it buys is the tool's own cleanup, such as a timer that
|
|
2208
|
+
would otherwise tick out its whole timeout with nobody left to answer. A tool that ignores it is left running
|
|
2209
|
+
rather than cancelled — the work is usually a job a server is already doing, and throwing away a result that is
|
|
2210
|
+
about to land helps nobody.
|
|
2211
|
+
|
|
2212
|
+
**`AgentAbort` and `AgentProgress` are re-exported from `akanjs/store`.** `AgentProgress` had no export path an app
|
|
2213
|
+
could legally use, `use-agentic` being a third-party import from `apps/**` and `libs/**`, so the documented advice
|
|
2214
|
+
to report progress from a slow tool was not followable. A tool that can neither report progress nor honour Stop is
|
|
2215
|
+
exactly the tool this release exists to make writable.
|
|
2216
|
+
|
|
2217
|
+
- 5f53462: An endpoint's declared `timeout` bounds the call, on both ends, instead of being read by nobody
|
|
2218
|
+
|
|
2219
|
+
`mutation(cnst.X, { timeout: 5 * 60 * 1000 })` could not work. The value had exactly one reader — the `Timeout`
|
|
2220
|
+
middleware, which no app registers because it is opt-in — it was never serialized to the client, `FetchPolicy.timeout`
|
|
2221
|
+
was accepted by the public type and dropped by `#makeHttpFn`, and `HttpClient` was constructed without one, so every
|
|
2222
|
+
non-upload request was abandoned at the client's hard-coded 30 seconds and restored as `base.error.gatewayTimeout`.
|
|
2223
|
+
Anything whose real work outlives half a minute — hardware provisioning, a firmware flow, an SSH round trip, an
|
|
2224
|
+
external orchestration — was unreachable from a browser, and the declaration on the endpoint read as if it were
|
|
2225
|
+
configured.
|
|
2226
|
+
|
|
2227
|
+
The declared value now travels. `FetchSerializer` puts it on the serialized endpoint and `fetch.<endpoint>()` makes
|
|
2228
|
+
it that request's budget, a caller overrides it per call with `{ timeout }` (`false` waits as long as the runtime
|
|
2229
|
+
will), and an app moves its own default with `fetch.instance.setTimeout(ms)` — precedence caller, endpoint,
|
|
2230
|
+
client default, with an upload still bounded by nothing. Server side the middleware is registered by default and has
|
|
2231
|
+
**no default of its own**: it stands aside for every endpoint that declared nothing, which is what makes registering
|
|
2232
|
+
it safe, and what made the 5-second fallback it used to apply the reason it could not be. It now rejects with a 504
|
|
2233
|
+
carrying `base.error.gatewayTimeout` rather than a bare `Error` that `SignalFailure` generalized to a 500, so both
|
|
2234
|
+
sides of a deadline produce the sentence the dictionary already had, and it clears its timer — the loser of the race
|
|
2235
|
+
was a pending timer per call, holding the event loop for the whole budget on a call that answered immediately.
|
|
2236
|
+
|
|
2237
|
+
What is still true, and is now written down: losing the race does not cancel the work. `Promise.race` cannot reach
|
|
2238
|
+
into `next()`, so a handler that timed out runs to completion with nobody holding its result. A `timeout` bounds
|
|
2239
|
+
what a caller waits for; an operation that must not half-happen needs its own idempotency.
|
|
2240
|
+
|
|
2241
|
+
`cache` had the same shape and got the same treatment. The `Cache` middleware was opt-in, ignored the declared
|
|
2242
|
+
`cache` value for a hard-coded 60 seconds, and — had anyone registered it — would have cached every endpoint in
|
|
2243
|
+
the app including mutations, under a key that names only the endpoint and its arguments, on a hit that skips
|
|
2244
|
+
`next()` and therefore skips the guards. It is now registered by default, reads the declared value as its TTL,
|
|
2245
|
+
runs `context.checkGuards()` before handing an entry over, and takes an entry only for a `query` that declares no
|
|
2246
|
+
internal argument — internal arguments are how a call learns who is asking, so an endpoint that has them answers
|
|
2247
|
+
per caller and a shared entry would be one caller's answer handed to the next. A `mutation` or a per-caller query
|
|
2248
|
+
that declares `cache` is named once in the log instead of being silently ignored, and a cache backend that is down
|
|
2249
|
+
is warned about rather than failing the call.
|
|
2250
|
+
|
|
2251
|
+
`Retry` is removed. Three attempts with a fixed backoff, on an endpoint whose failure it cannot classify, replays
|
|
2252
|
+
whatever the first attempt already did — and it was unreferenced.
|
|
2253
|
+
|
|
2254
|
+
- ccf2ae2: An app's or lib's typecheck no longer fails with TS7016 on `react-server-dom-webpack/client.node` once an install
|
|
2255
|
+
links the package into `akanjs`: the SSR renderer references the ambient declarations it needs itself.
|
|
2256
|
+
- 5f53462: fix: scope the auth cookie and storage key to the app so two apps on one host stop clobbering each other
|
|
2257
|
+
|
|
2258
|
+
Cookies carry no port (RFC 6265), so every app served from `localhost` shared one jar. Under the global `jwt`
|
|
2259
|
+
key each signin overwrote the neighbour's session, and the JWT's own `appName` check only turned the surviving
|
|
2260
|
+
cookie into a credential that fails every guard — the other app fell back to anonymous, guard failures, and a
|
|
2261
|
+
login wall.
|
|
2262
|
+
|
|
2263
|
+
The key is now `jwt:<appName>`, written by `setAuth` to both the cookie and client storage and read by
|
|
2264
|
+
`getAccount`, `initAuth`, the SSR `fetchClient` credential, the websocket credential snapshot, and the CSR boot.
|
|
2265
|
+
`authTokenKey()` and `getAuthToken()` are exported from `akanjs/client` (and `authTokenKey`, `readAuthToken`,
|
|
2266
|
+
`isOwnAuthToken`, `isAuthTokenKey`, `cookieHeaderHasAuthToken`, `legacyAuthTokenKey` from `akanjs/common`) so an
|
|
2267
|
+
app's own middleware reads the same key instead of spelling it out. Read the cookie through `getAuthToken()`
|
|
2268
|
+
rather than `getCookie("jwt")`.
|
|
2269
|
+
|
|
2270
|
+
`initAuth` now checks the token's `appName`/`environment` before handing it to `fetch.setJwt`, which it never
|
|
2271
|
+
did: a `?jwt=` for another app used to ride along on every request. A mismatch is ignored, and clears the cookie
|
|
2272
|
+
when it came from this app's own key.
|
|
2273
|
+
|
|
2274
|
+
**Breaking, with a migration path.** Reads still fall back to a pre-scoping `jwt` cookie, but only when the
|
|
2275
|
+
token's payload names this app — so an existing session survives the upgrade and a neighbour's leftover is never
|
|
2276
|
+
adopted. Writes only ever use the scoped key and delete the legacy one. The fallback is temporary; once
|
|
2277
|
+
deployments have rotated past it, the legacy read goes away and a stale session needs one re-login.
|
|
2278
|
+
|
|
2279
|
+
- 69f7178: fix: a `Boolean` argument parses the text the wire actually carries
|
|
2280
|
+
|
|
2281
|
+
A query string carries text, so `.search("archived", Boolean)` handed `Boolean._parse("true")` a string and threw
|
|
2282
|
+
`Invalid Boolean value: true` before the endpoint ran. The framework's own client sits on the other end of that —
|
|
2283
|
+
`HttpClient.makeUrl` writes `String(value)` — so no caller could reach such an endpoint, hand-written URL or not.
|
|
2284
|
+
`Int` and `Float` never had the problem because their `parseValue` is `Number(input)`, which absorbs a string;
|
|
2285
|
+
`Boolean` was the one primitive that compared against `true` / `1` and nothing else.
|
|
2286
|
+
|
|
2287
|
+
The normalization lives on the primitive rather than in the search-arg branch, because text is what three separate
|
|
2288
|
+
paths deliver: `URLSearchParams.get`, a path `.param()`, and every field of a `fileUpload` mutation's `FormData`.
|
|
2289
|
+
One place to normalize is also one place to keep the accepted set from drifting apart.
|
|
2290
|
+
|
|
2291
|
+
`true` / `false` / `1` / `0` are accepted, trimmed and case-insensitive — `str(True)` from a Python client is
|
|
2292
|
+
`"True"`, and a 500 is a poor answer to it. `""` and `"yes"` are still refused. An empty query value is a question
|
|
2293
|
+
about nullable search args in general rather than about booleans: `Int` reads `""` as `0` today and `String` reads
|
|
2294
|
+
it as a legitimate value, so answering it for one scalar alone makes the set less predictable, not more.
|
|
2295
|
+
|
|
2296
|
+
`serializeValue` takes the same spellings, since the client serializes an argument before `makeUrl` stringifies it
|
|
2297
|
+
— a `<select>` value handed to a `Boolean` argument now normalizes on the way out instead of throwing.
|
|
2298
|
+
|
|
2299
|
+
- 2d8de1c: feat: helpers the code cleanup made public
|
|
2300
|
+
|
|
2301
|
+
Consolidating duplicated helpers left a few new public names. Each is an addition; nothing was renamed or removed.
|
|
2302
|
+
|
|
2303
|
+
- `akanjs/common`: `isRecord(value)` (a type guard for an object that is neither `null` nor an array),
|
|
2304
|
+
`round(value, digits = 3)` and `toError(reason)` (an `Error` as is, anything else wrapped in one).
|
|
2305
|
+
- `akanjs/document`: the `SaveEventListener<Doc>` type, the listener `listenPre` / `listenPost` take.
|
|
2306
|
+
- `akanjs/server`: `ProcessMetricsCollector.startReporting(report)`, which reports once and then on every memory-log
|
|
2307
|
+
interval and returns the timer.
|
|
2308
|
+
- Subpath exports `akanjs/server/artifact/routeSeedIndexStore` and `akanjs/server/artifact/routesManifestStore`, so a
|
|
2309
|
+
published `@akanjs/devkit` can read and write those artifacts without loading the whole server barrel.
|
|
2310
|
+
|
|
2311
|
+
- 5f53462: fix(cli): scaffolds name their guards, and the mobile and package commands do what they say
|
|
2312
|
+
|
|
2313
|
+
Guards and templates:
|
|
2314
|
+
|
|
2315
|
+
- The module scaffold's slice is `{ root: Admin, get: Public, cru: Admin }` when the workspace mounts libs/shared,
|
|
2316
|
+
and `{ root: None, get: Public, cru: None }` — closed until you name a guard — when it does not; its sample slice
|
|
2317
|
+
and the sample app's `inPublic` take `init({ guards: [Public] })`. `cru: Public` is gone.
|
|
2318
|
+
- `akan add-mutation` / `add-slice` write `mutation(Boolean, { guards: [None] })` / `init({ guards: [None] })` and
|
|
2319
|
+
import `None`, and their surface step returns a review note instead of stopping as unsupported.
|
|
2320
|
+
- The sample app's `SignedIn` admits only a caller with an identity (`id`, or libs/shared's `self` / `me`); a guest
|
|
2321
|
+
carries an account object too and used to pass. `CurrentUserId` reads the same three places, and the sample's
|
|
2322
|
+
task fixtures sign a user in through libs/shared when it is installed. Its spec holds no assertions.
|
|
2323
|
+
- Every scaffolded abstract has the current shape — a title, one sentence and `## Rules` — instead of six headings
|
|
2324
|
+
of placeholder text: `create-module` writes `# <model> Abstract` with its guard and soft-removal rules,
|
|
2325
|
+
`create-service` writes `# <service> Service Abstract` and `create-scalar` `# <scalar> Abstract`, each with a
|
|
2326
|
+
placeholder sentence and two placeholder rules. The sample app's task, noti and workHistory abstracts state their
|
|
2327
|
+
real invariants instead of describing the module layout. The dead `module/__model__.abstract.md` template is
|
|
2328
|
+
removed.
|
|
2329
|
+
- `create-crud-page` imports from the module's own client (`@libs/<lib>/client` for a lib module), and
|
|
2330
|
+
`create-package` writes a tsconfig that extends the workspace root from any depth and references no missing file.
|
|
2331
|
+
- `akan add-field --default now` (and every Date default) writes a thunk, `() => dayjs()`.
|
|
2332
|
+
|
|
2333
|
+
CLI and mobile:
|
|
2334
|
+
|
|
2335
|
+
- iOS usage-description keys map to Apple's real Info.plist keys (`NSPhotoLibraryAddUsageDescription`,
|
|
2336
|
+
`NSPhotoLibraryUsageDescription`, both location-always keys) instead of `NS` + the option name.
|
|
2337
|
+
- A non-main server's `assetlinks.json` also lists `<appId>.debug`, so a debug build's App Links can verify.
|
|
2338
|
+
- `start-ios` takes `--no-allow-provisioning-updates` (short flag `-a`); every boolean option that defaults to
|
|
2339
|
+
`true` now accepts `--no-<name>`.
|
|
2340
|
+
- `akan update` installs `@akanjs/cli@<tag>`, the package that provides the `akan` binary.
|
|
2341
|
+
- `sync-package` reads package dependencies again (the tsconfig paths start with `./pkgs/`).
|
|
2342
|
+
- The quality scanner no longer recommends the nonexistent `akan compact`; `AbstractDoc.compactMinLines` is gone.
|
|
2343
|
+
- `--role` lists `federation`, and `--since` names its units.
|
|
2344
|
+
|
|
2345
|
+
- 8c31d54: fix: `akan code` says when compaction failed, leaves the Claude Code-sized buffer, and resolves its default model
|
|
2346
|
+
|
|
2347
|
+
**A failed compaction read as a successful one.** The engine reports a compaction that failed or was cancelled on
|
|
2348
|
+
the same `compaction_end` event as one that worked, with `errorMessage` and `aborted` beside the reason. The wire
|
|
2349
|
+
kept only the reason, so every end printed "Compacted the conversation" — including an automatic compaction that
|
|
2350
|
+
had failed, which nobody else reports, leaving the next sign of it an overflow error turns later. The `compaction`
|
|
2351
|
+
frame now carries `error` and `aborted`, and the transcript prints the engine's own sentence as a warning, or
|
|
2352
|
+
"Compaction cancelled.". A failed `/compact` still reports once, through the caller it threw to.
|
|
2353
|
+
|
|
2354
|
+
**The buffer is 32,768 tokens, up from the engine's 16,384.** On a million-token window that moves the threshold
|
|
2355
|
+
from 98.4% to 96.7% — where Claude Code compacts. The reserve is all the room the response at the threshold gets
|
|
2356
|
+
(the request clamps its output to the window less the prompt less 4,096), so at 16k a reasoning model ran out
|
|
2357
|
+
mid-thought there and the recovery re-sent a near-full window to retry. It is also the summary's output cap at 80%.
|
|
2358
|
+
The small-window warning now reads the settings the session actually runs with rather than the engine's defaults.
|
|
2359
|
+
|
|
2360
|
+
**The default model is `deepseek/deepseek-flash`.** `deepseek-v4-flash` is gone from both the engine's catalogue
|
|
2361
|
+
and DeepSeek's own model list, so the default resolved to nothing and `akan code` silently ran on whichever model
|
|
2362
|
+
sorted first among those with a key.
|
|
2363
|
+
|
|
2364
|
+
- ccf2ae2: fix: the server compresses only whole, modest bodies
|
|
2365
|
+
|
|
2366
|
+
`compressResponse` buffered and brotli- or gzip-compressed any body of a compressible type on the event loop, whatever
|
|
2367
|
+
its size, and answered a `Range` request with the whole body compressed. It now leaves alone a request with a `Range`,
|
|
2368
|
+
an answer that is partial (`206`, `Content-Range`) or not a success, and one whose `Content-Length` is over 4 MiB
|
|
2369
|
+
(compressing blocks the loop about 6 ms per MiB with brotli, 12 with gzip). The gateway keeps an upstream's
|
|
2370
|
+
`Content-Length` unless it had to drop the upstream's encoding, so the limit holds behind it too.
|
|
2371
|
+
|
|
2372
|
+
- 5f53462: fix(console): a production `akan console` brings services up and starts no schedule
|
|
2373
|
+
|
|
2374
|
+
The `console.js` an `akan build` writes never marked its process as a console, so `server.start()` took the replica
|
|
2375
|
+
branch: it registered every `interval` / `cron` / queue worker, ran every internal `init()`, and opened the log
|
|
2376
|
+
transport — a second scheduler inside a running deployment. The shim now marks the process before it loads the
|
|
2377
|
+
server, the same way `akan console` does in development, so a console runs service and adaptor `onInit` and nothing
|
|
2378
|
+
the internals schedule.
|
|
2379
|
+
|
|
2380
|
+
`assertAkanConsoleAllowed()` was handed `server.env`, which carries neither `environment` nor `operationMode`, so it
|
|
2381
|
+
effectively checked `NODE_ENV` alone. Called with no argument it now reads the deployment from `AKAN_PUBLIC_ENV` /
|
|
2382
|
+
`AKAN_PUBLIC_OPERATION_MODE` (deriving the mode the way `getEnv()` does): a `main` environment, a `cloud` or `edge`
|
|
2383
|
+
operation mode, or `NODE_ENV=production` refuses the console unless `AKAN_CONSOLE=1`.
|
|
2384
|
+
|
|
2385
|
+
- 5f53462: fix(signal): the cross-site gate compares hosts, so a TLS-terminating proxy no longer refuses every mutation
|
|
2386
|
+
|
|
2387
|
+
An app reached through a Cloudflare tunnel — or any edge that terminates TLS and forwards no
|
|
2388
|
+
`x-forwarded-proto` — answered `403 This request was not permitted.` to every mutation. The browser sends
|
|
2389
|
+
`Origin: https://app.example.com`; the request reaches the server over plain HTTP, so the origin it computed for
|
|
2390
|
+
itself was `http://app.example.com`, and the full-origin comparison made the app cross-site to itself.
|
|
2391
|
+
|
|
2392
|
+
`CrossSiteGuard.assertOrigin` now compares the `Origin` header's **host** against the host the request arrived on
|
|
2393
|
+
(`x-forwarded-host`, else `Host`), which is the comparison `McpRouter` already made for the same reason. The CSRF
|
|
2394
|
+
property is unchanged: the host is written by the browser from the URL it was told to open, so a page on another
|
|
2395
|
+
site still arrives with this deployment's host and its own `Origin`. Only the scheme distinction is dropped — the
|
|
2396
|
+
one part of an origin a proxy routinely loses — and what that costs is a page served over plaintext on the same
|
|
2397
|
+
host, which is an attacker who already holds the name. An explicit `:443` in a forwarded host and its absence in
|
|
2398
|
+
`Origin` now compare equal too.
|
|
2399
|
+
|
|
2400
|
+
A refusal logs the host it was measured against (`... (request host app.example.com)`), so a proxy misreporting
|
|
2401
|
+
the host reads differently from a missing `allowedOrigins` entry and from an attack.
|
|
2402
|
+
|
|
2403
|
+
- ccf2ae2: fix(dev): a CSR dev save reaches the open tabs before `app.js` is rewritten, and a tab opened in between boots once
|
|
2404
|
+
|
|
2405
|
+
A dev CSR save used to rewrite the whole `app.js` before telling the open tabs about the patch, although only a tab
|
|
2406
|
+
that boots later needs it. The patch is now announced first and `app.js` follows. The manifest records which
|
|
2407
|
+
generation `app.js` holds (`appGeneration`), and the server holds a booting tab's `app.js?g=N` for up to two seconds
|
|
2408
|
+
until the file holds `N`, instead of booting it one generation behind and reloading it again. A build that died in
|
|
2409
|
+
that gap leaves `app.js` behind; the next update rewrites it even when nothing else changed.
|
|
2410
|
+
|
|
2411
|
+
- ccf2ae2: perf(dev): a CSR dev save is patched by the resident builder, without spawning a build worker
|
|
2412
|
+
|
|
2413
|
+
- The dev builder keeps the CSR registry's graph, manifest and module factories in memory and compiles a save itself.
|
|
2414
|
+
A save no longer pays for a build worker's spawn and imports (about 130ms on minimal) or a pass over every module
|
|
2415
|
+
file, and `app.js` is rebuilt from memory after the patch is announced.
|
|
2416
|
+
- A save that needs a whole-app build still goes to a build worker, which exits and returns its memory: a first build,
|
|
2417
|
+
a config or signal/dictionary change, a new npm module, and an import no recorded resolution answers.
|
|
2418
|
+
- The builder works in two lanes. A save's codegen and CSR patch run in one, and the file watcher waits only for it;
|
|
2419
|
+
build workers, route builds and client-entry discovery run in the other, where a batch queued behind another folds
|
|
2420
|
+
into it. Consecutive saves no longer wait behind each other's pages and css builds, and css now builds before pages.
|
|
2421
|
+
- `AKAN_DEV_CSR_PATCHER=off` sends every CSR save to a build worker as before. A builder that dies mid-patch turns the
|
|
2422
|
+
patcher off until the dev server next replaces its builder and backend together (a config, signal or dictionary
|
|
2423
|
+
change), and says so.
|
|
2424
|
+
|
|
2425
|
+
- ccf2ae2: fix: a CSR page on another origin loads stored file URLs from the server
|
|
2426
|
+
|
|
2427
|
+
A file row keeps its URL relative to the server that wrote it (`/api/localFile/getBlob/...` with blob storage). A
|
|
2428
|
+
native shell serves the page from `app://localhost` or `https://app.localhost`, so the relative URL resolved against
|
|
2429
|
+
the app and the image never loaded. `resolveServerUrl` (`akanjs/client`) resolves an API-prefixed relative URL
|
|
2430
|
+
against `serverHttpUri` when the page's origin is not the server's, and leaves every other URL alone. `CsrImage`
|
|
2431
|
+
(so `Image` in a CSR bundle), agent attachments, and `libs/shared`'s editor images, videos, mention avatars, file
|
|
2432
|
+
download links, file gallery and Excalidraw images use it; Excalidraw stores the URL relative again.
|
|
2433
|
+
|
|
2434
|
+
- ccf2ae2: A CSR page renders when the app's root layout sits in a route group, inside the layouts that wrap it
|
|
2435
|
+
|
|
2436
|
+
- An app whose root layout is `page/(app)/_layout.tsx` rendered an empty `#root` on every CSR page — `?csr=true`,
|
|
2437
|
+
`/__csr` and native builds — with nothing in the console: the CSR route table left a route group's layout out of the
|
|
2438
|
+
root, so no route had a root layout, while the SSR route tree rendered the same pages fine.
|
|
2439
|
+
- The CSR route table and the SSR route tree now place layouts through one rule: the root layout is the first root
|
|
2440
|
+
boundary the page generator writes a `__root_layout` for, the one that carries `System.Provider`. Every layout below
|
|
2441
|
+
it — `(app)/(public)/_layout.tsx`, a nested boundary such as a `(tab)` group's — wraps the pages. In CSR that is what
|
|
2442
|
+
keeps a lock screen or a nav in `(public)/_layout.tsx` around the page instead of in the provider's hidden container.
|
|
2443
|
+
SSR renders the same element tree as before; those layouts' route segments are now keyed as layouts, not roots.
|
|
2444
|
+
- A route that still ends up with no root layout throws `[csr] no root layout for <path>` instead of rendering nothing.
|
|
2445
|
+
|
|
2446
|
+
- ccf2ae2: fix(csr): an entry of the same route shows its own content, the first page is cached, and a page nobody sees stops
|
|
2447
|
+
its effects
|
|
2448
|
+
|
|
2449
|
+
- **Same route, new entry, new content.** `/item?id=1` → `/item?id=2` kept showing the first entry: `RenderLayer`
|
|
2450
|
+
resolved a route's async render once per mount. It now renders again when an argument the route declares with
|
|
2451
|
+
`.param()` / `.search()` changes, layouts included, and keeps the previous result on screen until the new one lands.
|
|
2452
|
+
- **The page a session opens on is cached** like any page reached later. The history it seeded never put it in
|
|
2453
|
+
`cachedLocationMap`, so it unmounted two steps away.
|
|
2454
|
+
- **Every page container but the current one is `inert` and `aria-hidden`,** frame slot targets included, so Tab focus,
|
|
2455
|
+
a screen reader and the agent's `readScreen` no longer reach the page under the current one.
|
|
2456
|
+
- **A page nobody sees keeps its state but stops its effects.** A cached page, and the page a transition-less switch
|
|
2457
|
+
left (tab to tab) once it settled, render inside React's `<Activity mode="hidden">`: DOM and state stay, intervals
|
|
2458
|
+
and subscriptions stop, and both come back with the page. The page under an animated transition stays live, since a
|
|
2459
|
+
swipe back shows it.
|
|
2460
|
+
- `idxMap` and `scrollMap` are keyed by the entry's `href` on both sides. `CSR.tsx` read them by pathname, so the page
|
|
2461
|
+
under a query-string entry lost its stacking order.
|
|
2462
|
+
|
|
2463
|
+
- 5f53462: `akan start` sizes its boot wave against the machine instead of always booting one app at a time.
|
|
2464
|
+
|
|
2465
|
+
- With no `--concurrency`, the wave is `min(apps, half the memory budget / ~1.8GB per app, cores / 4)` and never
|
|
2466
|
+
below one, so a laptop boots its apps together while a small container still staggers them — which is the case
|
|
2467
|
+
the old default of 1 existed for. 1.8GB is a cold boot's peak: the SSR registry's boot build adds a build worker
|
|
2468
|
+
to every boot, and apps/akan's whole dev host peaks at about 1.76GB.
|
|
2469
|
+
- The next wave starts once each app of the previous one serves and its builder's boot builds have settled — the SSR
|
|
2470
|
+
registry's, and CSR's when `AKAN_DEV_CSR_REBUILD=1` arms it — or 30 seconds after it serves. The app shows as ready,
|
|
2471
|
+
and `--open` opens it, as soon as it serves.
|
|
2472
|
+
- The memory budget is the smaller of the host's RAM and `AKAN_MEMORY_LIMIT` / the cgroup limit. `os.freemem()` is
|
|
2473
|
+
not consulted: it counts free pages rather than reclaimable ones and reports ~0.3GB on an idle 48GB laptop, which
|
|
2474
|
+
would pin every machine to one app at a time.
|
|
2475
|
+
- The session prints the wave size and what set it, so a boot that still waits app by app says why.
|
|
2476
|
+
- `--concurrency <n>` is unchanged as an override and is clamped to the number of apps selected.
|
|
2477
|
+
|
|
2478
|
+
- ccf2ae2: fix(dev): dev documents skip the browser's HTTP cache, the HMR socket answers only its own origin, and superseded server bundles are removed during a session
|
|
2479
|
+
|
|
2480
|
+
- Under `akan start`, SSR pages and the dev CSR shell are sent with `Cache-Control: no-store`, so going back to a
|
|
2481
|
+
page no longer replays a document rendered by a build the server has moved past. `akan build` output is unchanged.
|
|
2482
|
+
- `/_akan/hmr` refuses an upgrade from a page on another origin, by the rule mutations already follow (the serving
|
|
2483
|
+
host, the native shells, and `allowedOrigins`): its messages carry build errors and the names of the files being
|
|
2484
|
+
edited, which a `--share` visitor's page or another site open in the same browser could otherwise read.
|
|
2485
|
+
- A render error's overlay clears once the page renders again, instead of staying on every tab until it reconnects.
|
|
2486
|
+
The HMR socket's reconnect backoff starts over at the server's hello rather than when the socket opens, so a
|
|
2487
|
+
gateway whose upstream keeps failing is no longer asked every 250ms.
|
|
2488
|
+
- A pages build that changes server output no longer leaves the bundle before it in `.akan/artifact/server` for the
|
|
2489
|
+
rest of the session (8MB on minimal, 21MB on apps/akan): each time the RSC worker takes a new bundle, the ones it
|
|
2490
|
+
has moved past that are over a minute old are removed, so a burst of saves leaves its bundles only until the next
|
|
2491
|
+
server-side save after it. A CSS change removes the stylesheets no tab can still link to the same way.
|
|
2492
|
+
- `getAkanHmrPhase()` and `isAkanHmrApplying()` from `akanjs/common` are deprecated: nothing sets the phase any more,
|
|
2493
|
+
so they always answer `null` and `false`.
|
|
2494
|
+
|
|
2495
|
+
- ccf2ae2: fix: a dev build of the iOS, Android or desktop app waits for a page the dev server is still building
|
|
2496
|
+
|
|
2497
|
+
The native dev gateway served with Bun's default 10 s idle timeout, so the first request for a page the dev server
|
|
2498
|
+
was still building (27 s for a small app on a Windows VM) was closed under the app, which then fell back to its
|
|
2499
|
+
bundled files: a dev build carries none, and the window stayed blank until the next reload. The gateway now keeps
|
|
2500
|
+
the request open until the dev server answers.
|
|
2501
|
+
|
|
2502
|
+
- ccf2ae2: feat(dev): a dev save now says where its HMR latency went
|
|
2503
|
+
|
|
2504
|
+
- Each save carries epoch-ms marks from the file watcher to the page: the first fs event and the watcher's flush,
|
|
2505
|
+
the builder picking the batch up, the build worker's spawn, start and imports, the update on disk, the send, the
|
|
2506
|
+
backend's broadcast, and the page receiving and applying it. The page keeps the last 64 in `__AKAN_HMR_TRACES__`.
|
|
2507
|
+
- `AKAN_DEV_WATCH_DEBOUNCE_MS` sets how long the watcher collects changes before it hands a batch over.
|
|
2508
|
+
|
|
2509
|
+
- 69f7178: fix(dev): `akan start` is the dev server even when the environment says `NODE_ENV=production`
|
|
2510
|
+
|
|
2511
|
+
An ambient `NODE_ENV=production` turned every route of the dev server into a 500:
|
|
2512
|
+
|
|
2513
|
+
```
|
|
2514
|
+
[SSR] render failed scope=/en/: [SSR] route /:lang missing from production artifact — rebuild with `akan build` to include it
|
|
2515
|
+
```
|
|
2516
|
+
|
|
2517
|
+
`WebRouter` picked its mode from `NODE_ENV` alone, so it installed the production route cache — the one whose
|
|
2518
|
+
`buildRoute` throws because `akan build` was supposed to have written a routes manifest already. A dev artifact
|
|
2519
|
+
never has one, so nothing could render, and the same ambient value also sent the gateway's runtime directory to
|
|
2520
|
+
`<workspace>/runtime` and baked production React into the dev bundles.
|
|
2521
|
+
|
|
2522
|
+
The value does not have to be exported to arrive: Bun auto-loads the workspace `.env`, so a `NODE_ENV=production`
|
|
2523
|
+
line in a downloaded env file reaches the CLI while `env | grep NODE_ENV` in the shell stays empty. A CI image
|
|
2524
|
+
default or a container base image does the same.
|
|
2525
|
+
|
|
2526
|
+
**The command now outranks the environment.** `akan start` pins `NODE_ENV=development` for the processes it
|
|
2527
|
+
spawns and for the in-process builder that bakes the value into dev bundles; `AkanApp` resolves a child's
|
|
2528
|
+
`NODE_ENV` from `AKAN_COMMAND_TYPE` before falling back to the inherited value; and `WebRouter` refuses the
|
|
2529
|
+
production branch outright while dev-hosted, warning once that it did rather than failing every request. Running
|
|
2530
|
+
a built artifact (`bun main.js`, Docker) is untouched — it carries no command type and keeps honouring
|
|
2531
|
+
`NODE_ENV`.
|
|
2532
|
+
|
|
2533
|
+
- 69f7178: fix(server): refuse the boot when two owners claim one `use` key or adaptor `refName`
|
|
2534
|
+
|
|
2535
|
+
Both registries were plain last-write-wins. A `use` key two libs both declared, or an adaptor `refName` two
|
|
2536
|
+
classes both carried, silently kept the second: the first value was gone and the replaced adaptor's `onInit`
|
|
2537
|
+
never ran. Neither left a trace, and an app whose `option.ts` shadowed a lib's key looked like it worked.
|
|
2538
|
+
|
|
2539
|
+
Registering either twice now fails `DiLifecycle` construction, naming the key and both claimants:
|
|
2540
|
+
|
|
2541
|
+
```
|
|
2542
|
+
[DI:adaptor] 1 duplicate registration(s):
|
|
2543
|
+
• "imageStorage" is registered by service "article" and by service "gallery"
|
|
2544
|
+
```
|
|
2545
|
+
|
|
2546
|
+
The check is per key, not per registration. One adaptor class reached from two services is one adaptor and
|
|
2547
|
+
passes; a predefined role rebound with `applyAdaptor` is one class and passes. Only two _different_ classes under
|
|
2548
|
+
one `refName` clash — which is the case that used to defeat an override silently, because the collected class won
|
|
2549
|
+
the map while the role kept pointing at the other one.
|
|
2550
|
+
|
|
2551
|
+
`AkanOption.getUses(env)` returns `[key, value][]` instead of a merged record, so a key declared twice inside one
|
|
2552
|
+
option is caught the same way as one declared across two libs. Nothing outside the framework calls it.
|
|
2553
|
+
|
|
2554
|
+
- 69f7178: fix(dictionary): restore the translate keys an extending dictionary inherits from the lib it extends
|
|
2555
|
+
|
|
2556
|
+
`ModelDictInfo` briefly carried an extra generic parameter, placed before `ErrorKey` and `EtcKey`. The three
|
|
2557
|
+
positional lists that merge two dictionaries — `AnyModelDictInfo`, and both halves of `MergeTwoModelDicts` — were
|
|
2558
|
+
not extended with it. Every parameter has a default, so supplying one too few is legal TypeScript: inference
|
|
2559
|
+
shifted by one slot and the last parameter fell off the end into its default.
|
|
2560
|
+
|
|
2561
|
+
For an app dictionary built as `modelDictionary(["en", "ko"], ...setting.dictionaries)`, that meant:
|
|
2562
|
+
|
|
2563
|
+
- `EtcKey` (the `.translate()` keys) became `never` — `l("setting.updateSuccessMsg")` for a key the _lib_
|
|
2564
|
+
declared stopped typechecking, in an app whose own source had not changed
|
|
2565
|
+
- `ErrorKey` was read one slot early, so `${refName}.error.${ErrorKey}` was built from the translate keys instead
|
|
2566
|
+
|
|
2567
|
+
Only the types were wrong. `modelDictionary` returns the base instance and the extending chain mutates it, so
|
|
2568
|
+
every entry was still there at runtime — which is why the failure surfaced only as a typecheck error in
|
|
2569
|
+
downstream workspaces, and why re-declaring the key in the app (the obvious workaround) silenced it while
|
|
2570
|
+
duplicating a translation the lib already owned.
|
|
2571
|
+
|
|
2572
|
+
Both a runtime test (an extending dictionary keeps the base's `translate`/`error` entries) and type assertions
|
|
2573
|
+
over the merged `EtcKey` now cover it, and the positional lists carry a note that adding a parameter means adding
|
|
2574
|
+
it in all three places.
|
|
2575
|
+
|
|
2576
|
+
- f882bbc: Resolve a dictionary key whose enum value contains a dot, so `l("llmModel.gpt-5.6-terra")` returns its label
|
|
2577
|
+
instead of the key itself.
|
|
2578
|
+
|
|
2579
|
+
Enum entries are stored under the value verbatim — `llmModel["gpt-5.6-terra"]` is one object key — but every
|
|
2580
|
+
reader went through `pathGet`, which splits the whole key on `.` unconditionally. A value carrying a dot was read
|
|
2581
|
+
as `llmModel → gpt-5 → 6-terra`, so it missed: `l()` and `l._()` rendered the raw key on screen (enum labels in
|
|
2582
|
+
`Field` selects, the constant and signal doc pages), and `DictionaryLookup` returned `undefined`, dropping the
|
|
2583
|
+
description from the MCP catalogue and the OpenAPI document. `<value>.desc` missed the same way. Nothing warned,
|
|
2584
|
+
because `TransMessage` builds the key union as `` `${refName}.${value}` `` and typechecks it fine.
|
|
2585
|
+
|
|
2586
|
+
The three readers now use `pathGetLoose`, a new `akanjs/common` export that tries joined prefixes when a plain
|
|
2587
|
+
segment walk finds nothing. It tries the shortest prefix first, so every key that resolved before resolves to the
|
|
2588
|
+
same node — only keys that used to fall through to the fallback are newly found.
|
|
2589
|
+
|
|
2590
|
+
- 2d8de1c: fix(ui): a disabled `Link` renders its `div` without the link-only props
|
|
2591
|
+
|
|
2592
|
+
With `disabled` (or no `href`), `Link` spread every remaining prop onto the `div` it renders, so `scrollToTop`,
|
|
2593
|
+
`replace`, `activeClassName`, `activeExact` and `noCache` reached the DOM and React warned about each. They are now
|
|
2594
|
+
dropped there; the caller's own attributes (`aria-*`, handlers, `data-*`) still reach the `div`.
|
|
2595
|
+
|
|
2596
|
+
- 69f7178: feat(akan): serve the framework documentation over MCP from the docs app
|
|
2597
|
+
|
|
2598
|
+
`apps/akan` now exposes its own generated documentation corpus as MCP tools, so an agent working in an Akan
|
|
2599
|
+
workspace can read the framework's docs instead of guessing at its APIs. Three tools, all `[Public]` — the same
|
|
2600
|
+
markdown is already served anonymously at `/llms/pages`, so a guard there would protect nothing while making the
|
|
2601
|
+
tools unusable to the agents they exist for:
|
|
2602
|
+
|
|
2603
|
+
- `listDocPages` — the whole index, optionally narrowed to one of the four sections.
|
|
2604
|
+
- `searchDocPages` — every given word must match, ranked with title hits ahead of body mentions.
|
|
2605
|
+
- `readDocPage` — one page in full as markdown, code examples included.
|
|
2606
|
+
|
|
2607
|
+
This is also the first app in the repo to serve a live `/mcp` catalogue, which is what makes the MCP boot log,
|
|
2608
|
+
the wire, and the refusal paths exercised by something other than a test.
|
|
2609
|
+
|
|
2610
|
+
The corpus under `public/llms/pages` is stale relative to the docs routes — regenerate with
|
|
2611
|
+
`bun apps/akan/script/generateLlms.ts` to pick up the MCP cheatsheet page and the mobile cheatsheets.
|
|
2612
|
+
|
|
2613
|
+
- 5f53462: The recovered-form banner is the app's to size and to skin.
|
|
2614
|
+
|
|
2615
|
+
- `Model.EditModal` takes `draftBarClassName`, so a dense screen — an editor sitting right under its own header —
|
|
2616
|
+
can trim the banner's padding and type without forking the shell.
|
|
2617
|
+
- `DraftBar` is an override slot, bound like any other in a `page/**/_overrides.tsx` manifest. The shell keeps the
|
|
2618
|
+
draft state and keeps publishing the restore and discard tools, so a replacement takes `state`, `savedAt`,
|
|
2619
|
+
`onRestore` and `onDiscard` as props instead of reaching into the store under string keys.
|
|
2620
|
+
- A draft the record has caught up with is dropped rather than offered back. A form that saves itself as the user
|
|
2621
|
+
types moves `updatedAt` with its own save, so every reopen read as a conflict and asked the user to settle a
|
|
2622
|
+
difference that was not there.
|
|
2623
|
+
- `Model.Edit` forwards `draft` the way `Model.New` already did, so a form opened through it can turn recovery off
|
|
2624
|
+
at all.
|
|
2625
|
+
|
|
2626
|
+
- 22aa04f: fix(ui): keep a Modal opened from a Dropdown menu alive
|
|
2627
|
+
|
|
2628
|
+
`Dropdown` dismissed on any `mousedown` outside its own DOM subtree, but `Dialog/Modal` leaves that subtree
|
|
2629
|
+
through `createPortal(document.body)`. A `Model.Edit` (or any Modal) rendered as a menu item therefore read
|
|
2630
|
+
every click inside itself as an outside click: the menu closed, its content unmounted, and the modal it had
|
|
2631
|
+
opened went with it — without an animation, and without running `onCancel`. Because `EditModal` derives its
|
|
2632
|
+
openness from the store rather than local state, the `<model>Modal === "edit"` left behind then re-opened
|
|
2633
|
+
the editor by itself the next time the menu mounted its content.
|
|
2634
|
+
|
|
2635
|
+
React context reaches through a portal where the DOM does not, so ownership is now explicit rather than
|
|
2636
|
+
inferred from the DOM: `Dropdown` hands its content a scope, and a `Modal` stamps whichever scope rendered
|
|
2637
|
+
it onto the roots it portals out (`data-akan-overlay`). A dismiss check then recognises the overlays it
|
|
2638
|
+
owns — including ones opened by a nested scope — and ignores clicks inside them, while an overlay it does
|
|
2639
|
+
not own dismisses it as any other outside click would.
|
|
2640
|
+
|
|
2641
|
+
Four changes:
|
|
2642
|
+
|
|
2643
|
+
- Dismiss checks ignore clicks inside an overlay the dismissing scope owns. A dropdown rendered _inside_ a
|
|
2644
|
+
modal is unaffected, since that modal is not its own — it still dismisses on an ordinary click in it.
|
|
2645
|
+
- The menu is hidden rather than unmounted once it has been opened, so an overlay a menu item opened
|
|
2646
|
+
survives the menu closing.
|
|
2647
|
+
- `EditModal` resets the modal it owns when it unmounts while open, whatever tore it down. The reset is
|
|
2648
|
+
deferred one microtask so a remount in the same commit (a re-keyed list row) claims the modal back instead.
|
|
2649
|
+
- A menu item marked `data-dropdown-keep-open` no longer closes the menu, so a switch or copy button inside
|
|
2650
|
+
a menu stays usable.
|
|
2651
|
+
|
|
2652
|
+
Both `Data.ListContainer` and `Data.Item` put action lists straight into a `Dropdown`, so list and item
|
|
2653
|
+
toolbars were affected the same way. An app-authored overlay that portals itself can join the same
|
|
2654
|
+
bookkeeping by spreading `useOverlayLayerProps()` onto its portal roots.
|
|
2655
|
+
|
|
2656
|
+
- 69f7178: fix(package): embed `use-agentic` into the akanjs build instead of depending on it
|
|
2657
|
+
|
|
2658
|
+
`use-agentic` is a workspace package, not a registry entry — it is not in `publishableAkanPackages` and
|
|
2659
|
+
`release:build-packages` never builds it — so the `"use-agentic": "0.1.0"` line the dependency scanner wrote into
|
|
2660
|
+
`pkgs/akanjs/package.json` pointed every consumer at something `bun install` cannot resolve.
|
|
2661
|
+
|
|
2662
|
+
The akanjs build now copies its source into `vendor/use-agentic` inside the dist and rewrites every
|
|
2663
|
+
`from "use-agentic"` specifier to the relative path, in the shipped `.ts` sources and in the emitted `.d.ts` tree
|
|
2664
|
+
alike — declarations for the embedded package are emitted into `types/vendor/use-agentic` by a second program, so
|
|
2665
|
+
the published types resolve without the package existing anywhere. `PackageRunner` treats it as a bundled runtime
|
|
2666
|
+
dependency the way `@akanjs/cli` already treats `@akanjs/devkit`, which keeps it out of both the dist and the
|
|
2667
|
+
source `package.json` on every rebuild.
|
|
2668
|
+
|
|
2669
|
+
`@happy-dom/global-registrator` moved to an optional peer dependency alongside `@playwright/test` and `chance`,
|
|
2670
|
+
the shape every other package the `test/` facet reaches already has. `test/registerDom.ts` is a value import, so
|
|
2671
|
+
the scanner had promoted a test-only DOM shim into the runtime dependencies of every app that installs akanjs.
|
|
2672
|
+
|
|
2673
|
+
Nothing changes inside the monorepo: framework source keeps importing `"use-agentic"` and keeps resolving it
|
|
2674
|
+
through the workspace. `GuideProps`, `ZoneProps`, and `AgentScopeProps` are now exported, because the `Agent`
|
|
2675
|
+
namespace object cannot have its declaration emitted while the props it infers cannot be named (TS4023) — that
|
|
2676
|
+
error had been failing `build-package akanjs` outright, since the release build runs the declaration emit with
|
|
2677
|
+
`AKAN_BUILD_DECLARATION_DIAGNOSTICS=error`.
|
|
2678
|
+
|
|
2679
|
+
- 69f7178: fix: the first field run's findings — request context, module identity, and an honest agent surface
|
|
2680
|
+
|
|
2681
|
+
Six fixes from running the in-page agent on a real product app, plus the one its own docs app hit.
|
|
2682
|
+
|
|
2683
|
+
**Server components see the request again.** The RSC worker wrapped a render in `requestStorage.run(...)` only,
|
|
2684
|
+
and the flight stream pumps components after that scope has exited — every `getSelf()` read no cookies and
|
|
2685
|
+
bounced authed pages to `/signin`. The worker now keeps a request fallback pushed until the render settles, the
|
|
2686
|
+
same discipline `ssrFromRscRenderer` already used. The stack is global and last-push-wins across concurrent
|
|
2687
|
+
renders; pumping the flight render inside the ALS scope itself is the follow-up that removes the caveat.
|
|
2688
|
+
|
|
2689
|
+
**`akanjs/fetch` is a vendor shared module.** It was bundled separately into the app and the vendored
|
|
2690
|
+
`akanjs/store` chunk, so `FetchClient`'s static serialized-signal registry split in two and the agent catalogue
|
|
2691
|
+
silently read the empty copy — every endpoint-named action refused as undescribed. The registry also moved to a
|
|
2692
|
+
`globalThis` symbol (the anchoring the shared client proxy already had), so a future duplicated module instance
|
|
2693
|
+
degrades to nothing instead of an empty surface.
|
|
2694
|
+
|
|
2695
|
+
**The relay's return scalar registers on the client graph.** `agentTurn` lived in `akanjs/signal`, which no
|
|
2696
|
+
client bundle loads, so the chat's first probe of `fetch.runAgentTurn` threw `No scalar constant model for
|
|
2697
|
+
agentTurn`. The scalar now lives in `akanjs/fetch` and `FetchClient` itself force-registers it — Bun links a
|
|
2698
|
+
barrel's `export *` modules lazily per used binding and its transpiler drops unused imports, so only a real call
|
|
2699
|
+
survives to do that.
|
|
2700
|
+
|
|
2701
|
+
**`labelOf` reads the `text: "title"` role as the `Set` it is.** It asserted `readonly string[]` and called
|
|
2702
|
+
`.find`, so every screen-context label on a search-indexed model threw.
|
|
2703
|
+
|
|
2704
|
+
**`akan build` relaunches itself once with the app env in place.** Bun macros snapshot `process.env` at process
|
|
2705
|
+
start, so the env the build resolves and publishes later is invisible to them — and any hand-written
|
|
2706
|
+
`@libs/<lib>/server` import (the sanctioned cross-lib entrypoint) puts that lib's env files, which read
|
|
2707
|
+
`getEnv()` at module scope, into the `getSerializedSignal` macro graph. `AKAN_PUBLIC_APP_NAME` is the one
|
|
2708
|
+
required key a workspace root `.env` cannot carry, so the CLI build now spawns itself again with it set and the
|
|
2709
|
+
child does the real build — the `AKAN_PUBLIC_APP_NAME=<app> akan build <app>` workaround is built in. The
|
|
2710
|
+
relaunch decision compares the env the process was **born** with, never the live `process.env`: the command
|
|
2711
|
+
dispatcher writes the resolved app name into `process.env` before any command method runs, which satisfies
|
|
2712
|
+
runtime readers but not macros — a guard reading the live value never fires (that inert shape is what alpha.19
|
|
2713
|
+
shipped). A `Relaunching with AKAN_PUBLIC_APP_NAME=…` line in the build output is the observable proof. The
|
|
2714
|
+
generated `sig`/`dict`/`srv`/`db` barrels also stopped walking parent _server_ barrels (they import the lib's
|
|
2715
|
+
own `@libs/<lib>/lib/<facet>` modules after `akan sync`), and `FetchClient.from` resolves its origin tolerantly,
|
|
2716
|
+
so a lib whose graph stays clean serializes with no env at all.
|
|
2717
|
+
|
|
2718
|
+
**The agent surface stops publishing two lies.** Form setters for the base document fields (`setIdOnX`,
|
|
2719
|
+
`setCreatedAtOnX`, `setUpdatedAtOnX`, `setRemovedAtOnX`) are no longer tools — the server stamps those. And an
|
|
2720
|
+
action named after an endpoint is published with the endpoint's schema only when it declares at least as many
|
|
2721
|
+
parameters: one declaring fewer would silently drop the borrowed tail and read stale form state, which shipped a
|
|
2722
|
+
resident SMS with the wrong unit number in the field run. Such an action is refused by name and warned once in
|
|
2723
|
+
the console; trailing extras beyond the endpoint's arguments stay legal, which is the generated
|
|
2724
|
+
`create<Model>(data, options?)` shape.
|
|
2725
|
+
|
|
2726
|
+
- 2d8de1c: fix(ui): component, client and projection fixes from the framework bug sweep
|
|
2727
|
+
|
|
2728
|
+
- **A server-rendered `Link` to a `#hash` keeps the caller's props** — `target`, `onClick`, `aria-*`, `data-*` —
|
|
2729
|
+
like every other `Link`.
|
|
2730
|
+
- **`Input.TextArea` attaches `inputRef` to the textarea** instead of spreading it onto the DOM, where React warned
|
|
2731
|
+
and the ref stayed empty.
|
|
2732
|
+
- **`System.Reconnect` removes the disconnect listener it added**, so remounts no longer stack listeners that each
|
|
2733
|
+
ping on every disconnect.
|
|
2734
|
+
- **`Agent.Context` renders on every environment except `AKAN_PUBLIC_ENV=main`**, like `Agent.Dock`; a
|
|
2735
|
+
shell-exported `NODE_ENV=develop` no longer hides it.
|
|
2736
|
+
- **The `LoadingArea` override slot takes `Loading.Area`'s props**, so `<Loading.Area className>` typechecks and an
|
|
2737
|
+
override can type its props.
|
|
2738
|
+
- **`System.Root` is flagged as deprecated where it is used**, not only on its props type.
|
|
2739
|
+
- **`useCodepush` no longer interrupts every release check with a debug `window.alert`.**
|
|
2740
|
+
- **`storage.removeItem` falls back to `localStorage` when the native Preferences call rejects**, as `getItem` and
|
|
2741
|
+
`setItem` already did.
|
|
2742
|
+
- **A projected read of a row written before a field existed gets that field's default the way a full read does**:
|
|
2743
|
+
an array gets its own fresh copy (pushing into it used to rewrite the model's default for every later document),
|
|
2744
|
+
and a required nested scalar gets its default instead of `null`.
|
|
2745
|
+
- **`logger.raw(msg)` writes the text as given**, like the static `Logger.raw`; it used to append a newline.
|
|
2746
|
+
|
|
2747
|
+
- 2d8de1c: fix(server): a replica keeps a pubsub room while any of its sockets is still in it
|
|
2748
|
+
|
|
2749
|
+
With two or more replicas (or under `akan start`), the gateway forwards a room's publishes to every replica that
|
|
2750
|
+
holds a socket in it. It tracked that membership per replica but applied each socket's unsubscribe to the whole
|
|
2751
|
+
replica, so when one of several sockets on a replica left a room, every other socket there stopped receiving
|
|
2752
|
+
publishes from the other replicas until the 30-second membership snapshot restored it. The gateway now removes a
|
|
2753
|
+
replica from a room only when the last of its sockets in that room leaves, and a snapshot keeps what it knows about
|
|
2754
|
+
the sockets of the rooms it confirms. A single-process deployment was never affected.
|
|
2755
|
+
|
|
2756
|
+
- 2d8de1c: fix(server): the gateway's `sockets` metric stops counting sockets whose room a snapshot dropped
|
|
2757
|
+
|
|
2758
|
+
The `sockets` count on `/_akan/app/metrics` is the number of sockets the gateway knows to be in at least one pubsub
|
|
2759
|
+
room. When a socket's unsubscribe never reached the gateway, the 30-second membership snapshot took the room away
|
|
2760
|
+
from the replica but left the socket counted, and every such socket stayed in the metric until that replica
|
|
2761
|
+
restarted. A room the snapshot no longer confirms now takes the sockets recorded in it along; a socket still in
|
|
2762
|
+
another room stays counted. Delivery never read this count, and the metric's name and shape are unchanged.
|
|
2763
|
+
|
|
2764
|
+
- f882bbc: Enforce `field(..., { immutable: true })` on the document write path instead of carrying it as decoration.
|
|
2765
|
+
|
|
2766
|
+
The option survived the v2 migration as metadata and nothing else. v1 handed it to mongoose, which enforced it;
|
|
2767
|
+
the SQL document layer that replaced mongoose never picked it up, so the only readers left were the schema doc's
|
|
2768
|
+
`immutable` pill and the devtools serializer. A field declared immutable was freely overwritten by
|
|
2769
|
+
`doc.set(...).save()`, by `srv.update(id, patch)`, and by every query-level write — while `akan`'s own docs
|
|
2770
|
+
promised "생성 후 수정할 수 없습니다".
|
|
2771
|
+
|
|
2772
|
+
`writeUpdatedDocument` is now the single gate: it compares each immutable field's prepared value against the row
|
|
2773
|
+
it loaded and throws when they differ. That one choke point covers the whole document path — `.set()` then
|
|
2774
|
+
`.save()`, a chain method assigning the field directly, and `update(id, patch)` — because all three land there.
|
|
2775
|
+
`.set()` itself stays a plain in-memory assign, so the throw arrives at `save()`, where the write actually
|
|
2776
|
+
happens; gating `.set()` too would have missed direct property assignment and gone silent on documents loaded
|
|
2777
|
+
through a read query, which carry no snapshot to compare against.
|
|
2778
|
+
|
|
2779
|
+
The check runs before the save hooks, so the message names the field the caller changed rather than one a
|
|
2780
|
+
`_preUpdate` derived from it, and it omits the values — an immutable field may also be `field.secret`.
|
|
2781
|
+
|
|
2782
|
+
Query-level writes are deliberately left open, mirroring the way mongoose exempts `bulkWrite`:
|
|
2783
|
+
`updateOneByQuery` / `updateManyByQuery`, the generated `update<Filter>` / `remove<Filter>`, and
|
|
2784
|
+
`updateById` / `removeById` compile straight to one SQL statement, fire no hooks, and read no existing row to
|
|
2785
|
+
compare against. A caller reaching for that path has already stepped outside document semantics. `create` and an
|
|
2786
|
+
upsert's insert are untouched, which is where an immutable field is meant to be set.
|
|
2787
|
+
|
|
2788
|
+
Re-saving an unchanged immutable field is not a change and does not throw, so `doc.save()` after editing anything
|
|
2789
|
+
else keeps working — including on a row written before the field was declared, which reads back as the field's
|
|
2790
|
+
default on both sides of the comparison.
|
|
2791
|
+
|
|
2792
|
+
- 69f7178: feat: component-level in-page agent — a chat that reads the rendered screen and drives it
|
|
2793
|
+
|
|
2794
|
+
The agent surface work so far described what an app _can_ do (the store catalogue, the MCP catalogue); this ships
|
|
2795
|
+
the half that knows what the _screen_ is doing and lets a user hand it the wheel. One mount —
|
|
2796
|
+
`<Agent.Chat />` in a layout — is the whole integration.
|
|
2797
|
+
|
|
2798
|
+
**`use-agentic`** is the new framework-independent core: a mount-lifetime surface
|
|
2799
|
+
(`registerTool`/`registerResource`/`openScope`/`registerGuide`, name stacks where the newest registration wins and
|
|
2800
|
+
unregistering restores what it shadowed), hooks (`useAgentState`/`useAgentTool`/`useAgentResource`/`useAgentGuide`,
|
|
2801
|
+
declarations mount-static and behavior always-latest), and `AgentSession` — the client-side conversation loop:
|
|
2802
|
+
send → model turn → tool calls → approval gate → execute → report resource diffs → next turn. The loop lives in the
|
|
2803
|
+
browser because the tools do; the server is one stateless turn relay that never executes anything. Failures land in
|
|
2804
|
+
the transcript rather than being thrown past it. The wire is documented in `WIRE.md` so any backend can serve it.
|
|
2805
|
+
|
|
2806
|
+
**The screen context is derived, not declared — and it follows the rendered screen, not the bundle.**
|
|
2807
|
+
`Load.Units`/`Load.View` register scopes and curated item lists as they mount (labels come from the `text: "title"`
|
|
2808
|
+
search role), `StoreInstance` counts which state keys the mounted components are reading — `st.use`, `st.sel`, and
|
|
2809
|
+
`st.ref` all count, the selector-based pair by running the selector once over a recording proxy — and a store's
|
|
2810
|
+
catalogued actions and state are published only while one of its keys is live. `AgentContext` assembles route +
|
|
2811
|
+
screen + live-state blocks per turn — primitives inline, everything else one masked `readState(key)` call away.
|
|
2812
|
+
`remove*` names default to a confirm gate, and the framework adds two built-ins beside `readState`: `navigate`
|
|
2813
|
+
(internal paths only, the same router `Link` rides) and `readScreen`, which serializes the rendered DOM into
|
|
2814
|
+
compact text — headings, links, control values with their `data-akan-*` annotations — so "what does this page
|
|
2815
|
+
say?" is answerable; the chat's own UI is skipped via `data-agent-ui` and a password value is never read.
|
|
2816
|
+
|
|
2817
|
+
**Exposure is the store author's to trim.** `static agent = false` keeps a whole store off the surface — the
|
|
2818
|
+
framework's base store declares it, which also stops `readState` from reaching the `tryJwt` credential — and
|
|
2819
|
+
`static agent = { exclude: [...] }` withholds named actions and state keys; `st.use.x({ agent: false })`
|
|
2820
|
+
subscribes without counting toward liveness. Generated `set<Key>` conveniences are no longer published at all:
|
|
2821
|
+
they carried no schema, so they listed as zero-argument levers that wrote `undefined`.
|
|
2822
|
+
|
|
2823
|
+
**`Agent.Chat`** is the user-facing half: launcher, transcript, composer, and the inline approval card, behind an
|
|
2824
|
+
`ssr: false` lazy boundary so none of it touches the server HTML, and re-skinnable through the `AgentChat`
|
|
2825
|
+
`_overrides.tsx` slot. Its default runner posts the wire to the app's own `runAgentTurn` route through
|
|
2826
|
+
`httpRunner`, which negotiates streaming via `accept`: the relay answers `text/event-stream` — one `RunnerEvent`
|
|
2827
|
+
per SSE `data:` line, assistant text arriving as it is generated — and a server that does not stream answers the
|
|
2828
|
+
same JSON turn. The endpoint reads the request with `.with(Req)` and returns a raw `Response` for the SSE half;
|
|
2829
|
+
`LlmAdaptor.chat` gained an optional `onDelta`, and an adapter that ignores it still answers whole. An app that
|
|
2830
|
+
mounts no relay degrades to a transcript message. `ThemeToggle` now publishes the current `theme` and a
|
|
2831
|
+
`setTheme` tool, so "switch to dark mode" works out of the box wherever it is mounted.
|
|
2832
|
+
A tool call is **one row** that resolves in place — pending, then `✓`/`✕` with the result's error or change count
|
|
2833
|
+
— rather than a badge on the assistant message and a second row for its result: the model needs both wire
|
|
2834
|
+
messages, but the user watched one thing happen, and the name appearing twice read as a doubled call. The row
|
|
2835
|
+
carries the call's arguments, because two searches are otherwise the same row twice.
|
|
2836
|
+
`<Agent.Guide instructions="..." />` layers route-scoped guidance by render position: nested Guides concatenate,
|
|
2837
|
+
navigation withdraws them. `prompt()` endpoints double as the chat's slash commands with no listing endpoint —
|
|
2838
|
+
the client reads its own serialized signals, and the prompt's GET enforces its guards at call time.
|
|
2839
|
+
|
|
2840
|
+
**The relay is framework-embedded.** `runAgentTurn`, the `agentTurn` scalar, `AgentTurnStream`, and the
|
|
2841
|
+
`AgentRelayAccess` guard ship with `akanjs` itself, registered the way the `base` module is — every app serves the
|
|
2842
|
+
relay with no lib to mount, `AKAN_AGENT=false` takes it off, and a lib that still carries its own `agent` module
|
|
2843
|
+
wins the refName so older workspaces keep working. `OpenaiLlm` (chat-completions REST, zero SDK dependencies)
|
|
2844
|
+
is the predefined default behind a new `LlmAdaptorRole`; an app swaps providers in its `option.ts` with
|
|
2845
|
+
`applyAdaptor(LlmAdaptorRole, OwnLlm)` — the same builder family as `applyMiddleware`, and the override mechanism
|
|
2846
|
+
works for every predefined adaptor role. The endpoint stays outside MCP — its `Any` bodies are refused from the
|
|
2847
|
+
catalogue — and `AgentRelayAccess` defaults to allow but now **warns at boot while no policy is registered**; a
|
|
2848
|
+
policy that throws fails closed.
|
|
2849
|
+
|
|
2850
|
+
**An app configures its server in `lib/option.ts`, not through the environment and not in `main.ts`.** `AkanOption`
|
|
2851
|
+
gained three setters, each read from every lib in mount order with the app's own last, so an app tightens what a
|
|
2852
|
+
library declared without restating it. `setLlm({ apiKey, model, host })` — or `setLlm((options) => …)` to take the
|
|
2853
|
+
key out of the app's own gitignored env object — reaches whichever adaptor holds `LlmAdaptorRole` as the
|
|
2854
|
+
`llmOption` use, replacing the per-provider environment names; the settings belong to the role
|
|
2855
|
+
rather than to one provider, so they survive a swap. `setAgentAccess(SignedIn)` names the guards
|
|
2856
|
+
`AgentRelayAccess` forwards to, which `AgentRelayAccess.use` takes at boot. `setMcp({ … })` carries the MCP server
|
|
2857
|
+
settings that `new AkanApp("./server", { mcp })` used to spell as child environment variables — the gateway there
|
|
2858
|
+
only spawns children, while `option.ts` is already handed to the process that mounts `/mcp`, so `AkanAppOptions.mcp`
|
|
2859
|
+
is gone. Every `AKAN_MCP_*` env spelling still works for a deployment configuring what the source does not, and an
|
|
2860
|
+
option written in code still wins over the env of the same name.
|
|
2861
|
+
|
|
2862
|
+
**`<Agent.Zone id="comments">` runs a second agent over one section, in parallel with the root.** Zones are views
|
|
2863
|
+
of the same surface, never walls: everything mounted inside — `st.use` subscriptions (liveness is now tagged with
|
|
2864
|
+
the ambient scope), hook tools, guides — belongs to the zone's own conversation _and_ stays visible to the root
|
|
2865
|
+
agent, so wrapping a section costs the root nothing. An `Agent.Chat` inside binds to the zone session
|
|
2866
|
+
automatically, a zone's `readScreen` reads only its own `data-agent-zone` container, and guides follow the layout
|
|
2867
|
+
cascade — ancestors and own, never a sibling's. The core grew `surface.view(path)` (`SurfaceView`), and
|
|
2868
|
+
`AgentSession` now runs over any view.
|
|
2869
|
+
|
|
2870
|
+
**`persist` keeps the transcript across reloads.** Off by default; `<Agent.Chat persist />` stores settled
|
|
2871
|
+
messages in sessionStorage (per-tab, gone when the tab closes), `{ storage: "local" }` outlives it, and each
|
|
2872
|
+
`Agent.Zone persist` keys its own entry by scope path. Restores drop the assistant draft a reload cut short,
|
|
2873
|
+
saves are debounced against streaming deltas, a versioned envelope discards stale wire shapes, only the newest 50
|
|
2874
|
+
messages are kept, and a full or blocked storage never breaks the chat. The chat header gained a clear button
|
|
2875
|
+
that empties both the transcript and the stored copy.
|
|
2876
|
+
|
|
2877
|
+
- f882bbc: Intern the in-page agent's React contexts on `globalThis`, so a zone's tools reach the zone's session.
|
|
2878
|
+
|
|
2879
|
+
An akan build inlines `use-agentic` into every client bundle that reaches it — four of them in this repo's own
|
|
2880
|
+
docs app — and a React context is identified by object identity. `AgenticSurface.shared` already survives that on
|
|
2881
|
+
a `Symbol.for` key; `SurfaceContext`, `ScopeContext` and `SessionContext` did not. So the `ScopeContext.Provider`
|
|
2882
|
+
an `Agent.Zone` rendered from one copy was invisible to the `st.tool` / `st.expose` / form tools reading it from
|
|
2883
|
+
another: `useScopePath()` fell back to `[]`, every declared tool registered at the root scope, and the zone
|
|
2884
|
+
session then filtered out all of them as belonging to a different view. The model was handed the built-ins and
|
|
2885
|
+
answered `Unknown tool` for tools the screen had published — with nothing thrown and nothing logged.
|
|
2886
|
+
|
|
2887
|
+
All three now go through one `sharedContext` helper, and a test fails if any module in the package makes a
|
|
2888
|
+
context the plain way: the plain call works in the monorepo, where there is one copy, and breaks only once
|
|
2889
|
+
bundled, so using the feature cannot catch it.
|
|
2890
|
+
|
|
2891
|
+
- f882bbc: Intern every framework React context on `globalThis`, so an override or a provider value cannot be lost to bundle
|
|
2892
|
+
chunking.
|
|
2893
|
+
|
|
2894
|
+
An akan build inlines each reachable module into every client chunk that reaches it — `client/sharedContext.ts`
|
|
2895
|
+
itself lands in two chunks of this repo's docs app — and a React context is identified by object identity. A
|
|
2896
|
+
Provider mounted from one copy is invisible to a consumer holding another, which reads the context default
|
|
2897
|
+
instead, silently.
|
|
2898
|
+
|
|
2899
|
+
`UiOverrideContext` was a plain `createContext`, so a route's `_overrides.tsx` bound its slots in the chunk
|
|
2900
|
+
holding the generated provider while overridable components in other chunks kept rendering the framework default.
|
|
2901
|
+
Nothing threw, and only _some_ slots were affected — which reads like a bug in the app's own component, not in
|
|
2902
|
+
the framework. The same shape had already cost a zone agent every tool its screen declared.
|
|
2903
|
+
|
|
2904
|
+
All ten of the framework's contexts now go through `sharedContext`, which is exported from `akanjs/client` so an
|
|
2905
|
+
app or lib can make its own the same way. A test fails if anything under `client/` or `ui/` calls `createContext`
|
|
2906
|
+
directly: the plain call works in the monorepo, where there is one copy of every module, and breaks only once
|
|
2907
|
+
bundled — so using the feature cannot catch it.
|
|
2908
|
+
|
|
2909
|
+
- 5f53462: perf: hydrate a model from one compiled plan per class, and build a Date field's dayjs on first read
|
|
2910
|
+
|
|
2911
|
+
`new cnst.X(raw)` and `set()` used to re-read the field map, resolve `getProps()` and dispatch on the field kind
|
|
2912
|
+
per field per instance, evaluated every default thunk before overwriting it, and wrapped every Date in a dayjs
|
|
2913
|
+
object — ~300 bytes and eight getter calls each — whether or not anything read it. A 1000-row listing paid 3.5ms
|
|
2914
|
+
and 1.7MB over the parsed JSON for that; it now pays 0.9ms and lands under the raw parse, because `HydrationPlan`
|
|
2915
|
+
compiles the field map once per class and a Date field keeps a native `Date` under a symbol slot, with the `Dayjs`
|
|
2916
|
+
its type promises built on first read and memoized per `Date`.
|
|
2917
|
+
|
|
2918
|
+
The accessors are on the prototype, so `Object.keys(model)` and `{ ...model }` no longer include Date fields.
|
|
2919
|
+
`"createdAt" in model`, `for...in`, `JSON.stringify` (via `toJSON`), `plainFieldsOf` (`akanjs/common`),
|
|
2920
|
+
`immerify` and `deepObjectify` all still do; copy a model with `new cnst.X().set(model)`. immer invokes the setter on
|
|
2921
|
+
the draft, so store writes stay copy-on-write.
|
|
2922
|
+
|
|
2923
|
+
Two hydration passes that never produced anything are gone as well: the browser no longer `structuredClone`s
|
|
2924
|
+
every query response — the request memo that copy protected exists only inside a request store — and
|
|
2925
|
+
`fetch.initX` builds its `xList` / `xInsight` instances only for a caller that reads them, which a route passing
|
|
2926
|
+
`xInit` down never did.
|
|
2927
|
+
|
|
2928
|
+
- 22aa04f: fix(ui): stop a Model chunk from repainting the page it opened over
|
|
2929
|
+
|
|
2930
|
+
`lazy()` returned the React lazy component with no boundary of its own, so a chunk that resolved after
|
|
2931
|
+
first paint suspended past it to the nearest boundary — the route. Opening a ticket modal or a `⋮` menu
|
|
2932
|
+
therefore replaced the whole page with the route's loading fallback, once per fresh load, until the chunk
|
|
2933
|
+
was cached. Portalling to `document.body` did not help: the React ancestor chain is unchanged.
|
|
2934
|
+
|
|
2935
|
+
`lazy()` takes `suspense?: boolean`, and only a flagged call site gets a `<Suspense>` of its own. The flag
|
|
2936
|
+
is opt-in because a boundary also changes server rendering: under the default `renderMode: "stream"` its
|
|
2937
|
+
subtree leaves the shell as `loading` and arrives later in the stream, which is right for an interaction
|
|
2938
|
+
shell and wrong for a page body that SEO snapshots, prerendering and pre-hydration E2E read out of the
|
|
2939
|
+
shell. Every existing call site keeps its exact code path.
|
|
2940
|
+
|
|
2941
|
+
The `Model` barrel turns it on for all fifteen exports, and gives `View` and `AdminPanel` a plain block
|
|
2942
|
+
to hold their height while the chunk loads. Those two are the only ones that occupy space of their own:
|
|
2943
|
+
the triggers wrap the caller's children, so a fixed block would put a slab where a small button belongs,
|
|
2944
|
+
and the modals render `null` until opened, so a block would appear where nothing ever shows.
|
|
2945
|
+
|
|
2946
|
+
The fallback is a host element and has to be. The barrel carries no `"use client"`, so a client component
|
|
2947
|
+
referenced from it resolves to `undefined` inside the thunk and the render throws "Element type is
|
|
2948
|
+
invalid", taking the whole page segment under the layout with it. Marking the barrel `"use client"` is
|
|
2949
|
+
worse still: `Model.*` becomes a real server→client boundary and apps that pass crystalized model
|
|
2950
|
+
instances as props fail serialization at boot. Plain markup crosses neither line.
|
|
2951
|
+
|
|
2952
|
+
`LazyOption.loading` also starts working on the default path, having been reachable only through
|
|
2953
|
+
`ssr: false` before.
|
|
2954
|
+
|
|
2955
|
+
- 69f7178: fix(sync): hold libraries to the root layout allowlist, and check a library's own `page/` tree
|
|
2956
|
+
|
|
2957
|
+
`apps/<app>/base` was refused by `akan sync`, `akan doctor` and `akan quality scan`; `libs/<lib>/base` passed all
|
|
2958
|
+
three. The rule lives in one file — `workspaceLayout.ts` exists precisely so the three tiers cannot disagree — but
|
|
2959
|
+
the call sites read it behind `exec.type === "app"` / `for (const app of context.apps)` / `segments[0] === "apps"`,
|
|
2960
|
+
so a library root was never compared to anything at all. A lib could carry a `base/`, a `helper.ts`, a stray
|
|
2961
|
+
`script/`, and every command stayed green.
|
|
2962
|
+
|
|
2963
|
+
Libraries now answer to their own allowlist, and it is the app's minus what only an app can use: no `main.ts`, no
|
|
2964
|
+
`capacitor.config.ts`, no `.akan` / `android` / `ios` / `mobile` / `script` / `secrets`, because a library is never
|
|
2965
|
+
booted or packaged. `index.ts`, `README.md` and `tsconfig.spec.json` are added, since a library ships as a package.
|
|
2966
|
+
`public/` and `private/` stay, because `syncAssets` symlinks them into each consuming app.
|
|
2967
|
+
|
|
2968
|
+
Three more places the same gate was one-sided:
|
|
2969
|
+
|
|
2970
|
+
- `akan doctor` walked `context.apps` only. It now walks libraries too and reports `lib-root-unknown-entry` beside
|
|
2971
|
+
the existing `app-root-unknown-entry`.
|
|
2972
|
+
- `akan quality scan` warned on an unexpected app root _file_ and nothing else — not a library root file, and not a
|
|
2973
|
+
root _folder_ on either side, which is the shape `base/` actually takes. It now reports all four
|
|
2974
|
+
(`akan.layout.{app,lib}-root-{file,folder}`). Its `lib/` facet rule kept its own copy of the allowlist and flagged
|
|
2975
|
+
a `<model>.signal.test.ts` that `akan sync` explicitly permits; both now read the shared helper, and the rule is
|
|
2976
|
+
renamed `akan.layout.lib-facet-file` so its name no longer collides with the library root it was never about.
|
|
2977
|
+
- A library's `page/` folder was validated only once an app opted into it with `syncPageLibs`. An app checks route
|
|
2978
|
+
filenames in `getPageKeys`, which a library has no equivalent of, so `libs/<lib>/page/Component.tsx` synced clean
|
|
2979
|
+
in the library it belongs to and failed in somebody else's app. `akan sync <lib>` now reports it as a scan
|
|
2980
|
+
violation, from the same rule `validatePageSourceFile` throws — split out as `getPageSourceFileViolation` so the
|
|
2981
|
+
reason is available where throwing is the wrong answer.
|
|
2982
|
+
|
|
2983
|
+
- 2d8de1c: fix(ui): mark `Link`'s `noCache` deprecated — it has never done anything
|
|
2984
|
+
|
|
2985
|
+
Neither renderer reads it: the server-rendered and CSR links navigate the same way with or without it, and
|
|
2986
|
+
`router.push` / `replace` take no cache option to hand it to. The prop is still accepted, so nothing breaks, but
|
|
2987
|
+
`<Link noCache>` now shows as deprecated in the editor. Drop it; to fetch a page fresh after a change, clear the
|
|
2988
|
+
route cache with `clearRscNavigationCache()` from `akanjs/client`.
|
|
2989
|
+
|
|
2990
|
+
- 5f53462: The LLM seam names a wire, not a vendor
|
|
2991
|
+
|
|
2992
|
+
Three provider classes shipped where there are only two wires, and the framework's own vocabulary had grown a
|
|
2993
|
+
per-vendor shape that nothing outside the framework could extend: `agent.error.deepseekRequestFailed`,
|
|
2994
|
+
`openaiRequestFailed` and `anthropicRequestFailed` were three dictionary keys an app-authored adaptor could never
|
|
2995
|
+
add a fourth to, and `LlmOption` was a closed bag, so an adaptor needing a region or a project id had to open a
|
|
2996
|
+
second config channel beside `setLlm`. `LlmAdaptorRole` and `applyAdaptor` were already the whole contract; what
|
|
2997
|
+
was wrong was how much the framework claimed to know about who fills it.
|
|
2998
|
+
|
|
2999
|
+
**`DeepseekLlm` is gone and `OpenaiLlm` is the default.** They spoke the same wire through the same
|
|
3000
|
+
`OpenaiDialect` and differed only in a host, a model name and a vision claim, so one host-pointed class covers
|
|
3001
|
+
DeepSeek, Groq, Together, OpenRouter, Ollama and a self-hosted vLLM as well as OpenAI. **An app relying on the
|
|
3002
|
+
old default must now name `model` and `host`** — `option.setLlm({ apiKey, model: "deepseek-v4-flash", host:
|
|
3003
|
+
"https://api.deepseek.com" })`. There is no default model, for the reason there never was one on `OpenaiLlm`: a
|
|
3004
|
+
model name ages out of a catalogue into a 404 at the first turn.
|
|
3005
|
+
|
|
3006
|
+
**`OpenaiLlm` claims vision for its default host and nothing else.** OpenAI's own endpoint takes image parts, so
|
|
3007
|
+
that is what it answers with no `host` set; a host the app named is a gateway the class knows nothing about, and
|
|
3008
|
+
it is text-only until `option.setLlm({ accepts })` says otherwise. Handing bytes to a model that cannot decode
|
|
3009
|
+
them kills the whole turn on a 400, where text-only degrades them to a note the model can repeat back — so the
|
|
3010
|
+
safe direction is the default and the claim is declared, not guessed.
|
|
3011
|
+
|
|
3012
|
+
**One refusal key, `agent.error.llmRequestFailed`, carrying `{ provider, status, reason }`.** `provider` is the
|
|
3013
|
+
hostname that refused rather than a brand name, because an adaptor pointed at a gateway would otherwise credit
|
|
3014
|
+
OpenAI for that gateway's answer. An adaptor an app wrote reports through the same translated sentence the
|
|
3015
|
+
shipped ones do.
|
|
3016
|
+
|
|
3017
|
+
**`setLlm` is generic, so `LlmOption` is a floor rather than the whole shape.** Whatever else it is handed
|
|
3018
|
+
travels to the role untouched: an adaptor declares its own interface extending `LlmOption`, reads it back with
|
|
3019
|
+
`use<MyLlmOption>()`, and its provider-specific settings ride the channel the built-in ones do.
|
|
3020
|
+
|
|
3021
|
+
Step-by-step migration: `akan guideline show workspaceRecipes` (or `get_guideline workspaceRecipes`),
|
|
3022
|
+
Recipe 8.
|
|
3023
|
+
|
|
3024
|
+
- 5f53462: A server-side loopback fetch follows the port the process was actually run with, so a container started with
|
|
3025
|
+
`PORT=80` renders instead of failing every page with `base.error.serverUnreachable`.
|
|
3026
|
+
|
|
3027
|
+
- `getEnv().serverPort` fell back to a literal `8282` on the server side, and only `akan start` ever set
|
|
3028
|
+
`AKAN_PUBLIC_SERVER_PORT`. A production image overriding the Dockerfile's `ENV PORT=8282` therefore bound one
|
|
3029
|
+
port and pointed the RSC worker's `fetch.*` at another; the SSR render failed on every route, with no other
|
|
3030
|
+
symptom than the transport error. When the origin resolves to `localhost` — the self-call — the port now comes
|
|
3031
|
+
from `PORT`. A `SERVER_HOST` naming another host is not a self-call and keeps the explicit port, and
|
|
3032
|
+
`AKAN_PUBLIC_SERVER_PORT` still outranks both.
|
|
3033
|
+
- `AkanApp` publishes the port it resolved as `PORT` to the replica it runs in-process and to every child it
|
|
3034
|
+
spawns, so `new AkanApp({ port })` reaches the tree the same way the env var does. In solo mode that is also
|
|
3035
|
+
what makes the option bind: `AkanServer` reads `PORT`, so an explicitly configured port was previously ignored.
|
|
3036
|
+
- `akan build` no longer publishes `AKAN_PUBLIC_CLIENT_PORT` / `AKAN_PUBLIC_SERVER_PORT` into the build process's
|
|
3037
|
+
own env. Bundling `define`s every `AKAN_PUBLIC_*` into a literal, so with `PORT_OFFSET` set the builder's dev
|
|
3038
|
+
port was baked into the artifact, where no deployment env could override it.
|
|
3039
|
+
|
|
3040
|
+
- f882bbc: fix: a masked field carrying a `text` role is a compile error, not a failed boot
|
|
3041
|
+
|
|
3042
|
+
**`field.secret`, `field.hidden` and `resolve()` no longer accept a `text` role in their options.** The role puts a
|
|
3043
|
+
plaintext copy of the value in the search mirror, so pairing it with masking publishes exactly what the masking
|
|
3044
|
+
hides — the class build has always refused it. But the refusal fired during module evaluation, which means the
|
|
3045
|
+
first sign of it was a backend that would not boot: three failed boots and then every route answering 503, with
|
|
3046
|
+
typecheck and lint silent the whole time, because the option type was the same one plain `field` takes.
|
|
3047
|
+
|
|
3048
|
+
Removing `text` from those three option types moves the refusal to the call site, where the fix is obvious and the
|
|
3049
|
+
editor shows it. The option type is a union, so the exclusion is distributive — a plain `Omit` over it would
|
|
3050
|
+
collapse to the keys its members share.
|
|
3051
|
+
|
|
3052
|
+
**The class-build throw stays as the backstop** and now names the way out ("Drop the text role, or make the field
|
|
3053
|
+
plain") instead of only what is wrong. It is still the only check that can catch the cross-file case, where a
|
|
3054
|
+
scalar's own field carries a role and a parent in another file masks it — the excess-property check cannot see
|
|
3055
|
+
an option object built elsewhere either.
|
|
3056
|
+
|
|
3057
|
+
- 5f53462: fix(mcp): `AKAN_MCP=false` keeps `/mcp` off whatever an `option.ts` configures
|
|
3058
|
+
|
|
3059
|
+
`setMcp()` with an object read a missing `enabled` as `true`, so any `option.ts` that only configured the surface
|
|
3060
|
+
switched it back on. `libs/shared` hands over its OAuth settings exactly that way, so every app mounting it served
|
|
3061
|
+
`/mcp` under `AKAN_MCP=false`. An object without `enabled` now leaves the switch where it was, and the env switch only
|
|
3062
|
+
ever narrows: `AKAN_MCP=false` (or `AKAN_PUBLIC_MCP=false`) turns the surface off even over an explicit `enabled: true`.
|
|
3063
|
+
|
|
3064
|
+
- 5f53462: fix(service): a `memory(...)` value that is a scalar or model class round-trips on both caches
|
|
3065
|
+
|
|
3066
|
+
`memory(Map, { of: SomeScalarInput })` typechecked and serialized through the constant, then handed the
|
|
3067
|
+
resulting object to a cache that holds a string, a number or a Buffer. The sqlite-backed `SolidCache` happens to
|
|
3068
|
+
JSON anything else on its own, so it worked there; Redis passes the value to `hset`, which coerces it to
|
|
3069
|
+
`"[object Object]"`. The same declaration meant two different things per deployment, and the failure only
|
|
3070
|
+
surfaced on read — so apps hand-encoded JSON into a `String` memory to get a structured value back.
|
|
3071
|
+
|
|
3072
|
+
A value whose declared type is not a primitive scalar now travels as JSON text in both directions, decided by
|
|
3073
|
+
the declaration rather than by the runtime value, so both adaptors store and return the same thing. A value that
|
|
3074
|
+
arrives already parsed — a row written before this change, or `SolidCache`'s own `json` type — is still read
|
|
3075
|
+
correctly.
|
|
3076
|
+
|
|
3077
|
+
- ccf2ae2: `akan start-desktop`, `start-ios` and `start-android` print a page's log once, at the level it was logged, and say a
|
|
3078
|
+
boot came up in one line
|
|
3079
|
+
|
|
3080
|
+
- A boot that comes up prints `cmdc ios ready · iPhone 16 · http://localhost:8283 · 12.4s` and nothing else; its steps
|
|
3081
|
+
(prepare, build, install, launch) are debug. A boot that fails prints every step it took ahead of the error. A native
|
|
3082
|
+
config warning still prints as it happens.
|
|
3083
|
+
- A page line reads `WARN [page:WsClient] WebSocket message process failed …`: stamped once by the CLI's logger, at
|
|
3084
|
+
the page's own level, with no `[default]` label, no empty line after it, and a multi-line message kept together. A
|
|
3085
|
+
page logger writes `[Name] message` at the console method of its level inside a native shell, and the desktop host
|
|
3086
|
+
and iOS tag each line `[page<+><#window> <level>]` for the CLI to read back.
|
|
3087
|
+
- Android forwards the page console through the bridge in a dev build too, so logcat carries each line at its level
|
|
3088
|
+
and a `console.error("%s", …)` substitution the way the devtools show it; iOS writes os_log at the page's level. An
|
|
3089
|
+
uncaught error forwards its message along with WebKit's frames, which carry none.
|
|
3090
|
+
- Frame tracing (`[akan:frame:…]`) is opt-in (`?akanFrameDebug=1` or `localStorage["akan:debug:frame"] = "1"`) and
|
|
3091
|
+
logs at debug, once per event; a mobile or desktop target no longer turns it on for every visibility change.
|
|
3092
|
+
- Lines no page or host wrote (the simulator, WebKit) and where the host's pages come from are debug; `--verbose` shows
|
|
3093
|
+
the former. `start-desktop` and friends ask a dev server whose builder idled out for its health, not its home page,
|
|
3094
|
+
so they no longer report it missing while it wakes.
|
|
3095
|
+
|
|
3096
|
+
- 5f53462: fix(service): a required nested scalar fills from its own field defaults instead of failing the write
|
|
3097
|
+
|
|
3098
|
+
`field(Coordinate)` on a model whose scalar carries a default for every one of its own fields still had to be
|
|
3099
|
+
written `field(Coordinate, { default: () => new Coordinate() })`, or `create({ name })` threw
|
|
3100
|
+
`Missing required field: location`. Every other layer already disagreed: `getDefault` recurses into a nested
|
|
3101
|
+
scalar, `HydrationPlan` constructs one, and `sampleOf` fills one — only the SQL write path treated an absent
|
|
3102
|
+
scalar as a missing value rather than a constructible one.
|
|
3103
|
+
|
|
3104
|
+
`prepareDocument` now applies the same rule `getDefault` does, in the same order — a parent-level `default`
|
|
3105
|
+
first, then `nullable` (an optional nested scalar stays absent), then the scalar's own defaults. A **relation**
|
|
3106
|
+
is unchanged and still fails closed: only the caller knows which row it names.
|
|
3107
|
+
|
|
3108
|
+
The read path had the matching hole. A row written before the field was declared carries no value for it, and a
|
|
3109
|
+
scalar has no primitive `DEFAULT_VALUE` to fall back on, so `decodeDocumentPayload` handed back `null` — which
|
|
3110
|
+
the next `save()` of that row rejected as `Field is not nullable`. It now constructs the same defaults, so an
|
|
3111
|
+
existing row migrates by being saved.
|
|
3112
|
+
|
|
3113
|
+
Array defaults are copied per document rather than shared. A field with no declared default is given `[]` at the
|
|
3114
|
+
class build, and both the store and `getDefault` handed that one array to every document filled from the model,
|
|
3115
|
+
so a `doc.tags.push(...)` landed in the field default and in every document created after it. The constant layer
|
|
3116
|
+
was already safe — `crystalize` copies an array on the way into an instance — and the document path now is too.
|
|
3117
|
+
|
|
3118
|
+
- c45ac6c: fix(lint): stop `//!` markers from shipping to the browser
|
|
3119
|
+
|
|
3120
|
+
Bun's bundler classifies `//!` and `/*!` as legal comments — the `@license` / `@preserve` class it must preserve
|
|
3121
|
+
for licence compliance — and keeps them through `minify: true`. Everything else goes: `//`, `/* */`, `//*`, `//?`,
|
|
3122
|
+
and `// TODO:` are all stripped. But the workspace `AGENTS.md` prescribes `//!` as in-code marker 4, "disabled or
|
|
3123
|
+
must-fix code", so the convention itself guaranteed that internal notes about broken and unfinished code shipped
|
|
3124
|
+
verbatim to every visitor. A deployed app served eight of them in one chunk, including `//!env로 옮겨야함`,
|
|
3125
|
+
`//!need to change`, and `//! Need to fix after bunjs migration`.
|
|
3126
|
+
|
|
3127
|
+
There is no bundler-side fix: `legalComments` is not a `Bun.build` option, and `Bun.Transpiler.transformSync`
|
|
3128
|
+
preserves `//!` too. The source is the only place to stop it.
|
|
3129
|
+
|
|
3130
|
+
`no-bang-comment-in-client.grit` now bans the marker in browser-reachable code — `ui/`, `webkit/`, `common/`,
|
|
3131
|
+
`page/**/*.tsx`, `*.constant.ts`, `*.store.ts`, and the five module component suffixes — while leaving it legal in
|
|
3132
|
+
server, `srvkit/`, and CLI files, which never reach a bundle. The five existing occurrences in `akanjs` and the
|
|
3133
|
+
libs became `// FIXME:`.
|
|
3134
|
+
|
|
3135
|
+
The rule anchors the marker to line start or to whitespace after code, so a literal like `"https://host//!path"`
|
|
3136
|
+
does not trip it. Comments are trivia rather than nodes, so the diagnostic spans the module and a marker on a
|
|
3137
|
+
file's very first line is invisible to the pattern — the one case the rule cannot see.
|
|
3138
|
+
|
|
3139
|
+
Rebuilding `apps/akan` takes the bundle from two `//!` comments to zero, with the third-party `/*!` licence
|
|
3140
|
+
blocks left intact.
|
|
3141
|
+
|
|
3142
|
+
- 2d8de1c: fix(oauth): read a Basic client credential whatever the scheme's case
|
|
3143
|
+
|
|
3144
|
+
`/oauth/token` and `/oauth/revoke` recognized HTTP Basic client authentication only when the scheme was spelled
|
|
3145
|
+
`Basic`. RFC 7235 makes an auth scheme case-insensitive, so a client sending `basic` or `BASIC` was read as sending
|
|
3146
|
+
no credential at all, and a confidential client was then refused. The scheme's case is the only thing that
|
|
3147
|
+
loosened: the single space after it, the decoding of the two halves and the one-channel rule are unchanged, so a
|
|
3148
|
+
lowercase `basic` header beside a `client_secret` in the body is now refused like a `Basic` one.
|
|
3149
|
+
|
|
3150
|
+
- ccf2ae2: `akan start` replaces its backend one restart at a time, so saves in quick succession can no longer leave a second
|
|
3151
|
+
gateway holding the port.
|
|
3152
|
+
|
|
3153
|
+
- A save whose restart came due while the previous one still waited for the old backend to exit used to signal the
|
|
3154
|
+
same process again and then spawn a second backend beside the first. The host kept the one that lost the port,
|
|
3155
|
+
restarted it on every save, and the untracked one went on answering. A restart that comes due now runs after the
|
|
3156
|
+
one in flight, and a config or metadata recycle and `akan start`'s shutdown wait for it too.
|
|
3157
|
+
- A gateway that cannot bind its port no longer reports itself ready while it waits for its replicas to exit.
|
|
3158
|
+
|
|
3159
|
+
- 22aa04f: fix(ui): give Escape to the topmost overlay, and size the Select panel to its options
|
|
3160
|
+
|
|
3161
|
+
`Modal` was the only overlay that closed on Escape, and it listened on `window` per instance — so two
|
|
3162
|
+
stacked dialogs both heard the same key and went down together. Escape now runs through one shared stack
|
|
3163
|
+
(`useEscapeKey`) that hands the key to the surface opened last, and `BottomSheet` and `Popconfirm` are on
|
|
3164
|
+
it too. A confirm popover inside a modal closes itself and leaves the modal standing.
|
|
3165
|
+
|
|
3166
|
+
`Select`'s open panel was a fixed `h-[270px]`, because `height` cannot ease to `auto` and the open/close
|
|
3167
|
+
transition needed something to animate. A three-option list therefore rendered with ~100px of dead space
|
|
3168
|
+
below it. The transition moves to `max-height`, which animates just as well and lets the panel end where
|
|
3169
|
+
its options do, and the always-on scrollbar becomes `overflow-y-auto`.
|
|
3170
|
+
|
|
3171
|
+
Also: the dialog surface takes `outline-none`. Focus moves onto it when the dialog opens so the tab order
|
|
3172
|
+
starts inside, but Chrome treats that programmatic focus as keyboard focus whenever the last interaction
|
|
3173
|
+
was a key — or a click on a non-button trigger — and drew a ring around the whole surface. Controls inside
|
|
3174
|
+
keep their own focus rings.
|
|
3175
|
+
|
|
3176
|
+
And `Tooltip`'s wrapper takes `w-fit`: `inline-flex` alone still stretches when the trigger is an item of a
|
|
3177
|
+
column flex container, and the bubble's `left-1/2` then centred on that full width, far from the trigger.
|
|
3178
|
+
|
|
3179
|
+
`Dropdown`'s panel loses the `gap-2` between rows and the stray `pr-3`, which together padded a three-item
|
|
3180
|
+
menu out to 132px of mostly empty space; rows now sit `gap-0.5` apart inside symmetric padding.
|
|
3181
|
+
|
|
3182
|
+
- f882bbc: fix: a `_overrides.tsx` manifest reaches what the root layout renders, and what an overlay host renders for it
|
|
3183
|
+
|
|
3184
|
+
**The override provider was mounted inside the root-layout stream, so the two most common positions a component
|
|
3185
|
+
is rendered from were beyond every manifest's reach.** Route rendering composes
|
|
3186
|
+
`[...renderRootLayouts, ...renderLayouts]`, and the override render rode the second stream — so a root-boundary
|
|
3187
|
+
layout wrapped the provider instead of sitting inside it. Anything such a layout renders beside `{children}` (an
|
|
3188
|
+
agent chat, a dock, a shell control) resolved every slot to its framework default, and so did anything drawn
|
|
3189
|
+
through the overlay host that layout mounts, which is where a portalled `Modal` renders. Both failed silently:
|
|
3190
|
+
the provider was mounted, with the right slot map, one level too deep.
|
|
3191
|
+
|
|
3192
|
+
Root boundaries are `/`, `/:lang` and `/:lang/<basePath>`, and a route **group adds no path segment**, so
|
|
3193
|
+
`page/_layout.tsx` and `page/(group)/_layout.tsx` are both root layouts — which is exactly where the framework's
|
|
3194
|
+
own guidance says to mount `<Agent.Chat />`.
|
|
3195
|
+
|
|
3196
|
+
**Overrides now wrap the whole stack, root layouts included.** `renderRootLayouts` is composed as
|
|
3197
|
+
`[...overrideRenders, ...rootLayoutStack]` and the CSR/mobile builder mirrors it. Nested manifests still stack in
|
|
3198
|
+
node order, so the closest declaration wins; the recursion carries the override-free root-layout array, because
|
|
3199
|
+
the root-boundary test counts its length. No route type or segment-identity change: overrides ride the stream the
|
|
3200
|
+
client already labels `"root-layout"`.
|
|
3201
|
+
|
|
3202
|
+
The regression tests assert the **resolved output** of a route whose root layout — and, separately, whose
|
|
3203
|
+
route-group layout — renders a slot-bound component. Asserting that the provider is an ancestor would not have
|
|
3204
|
+
caught this: it was.
|
|
3205
|
+
|
|
3206
|
+
- 2d8de1c: fix(release): the published packages no longer carry test-run files from `local/`
|
|
3207
|
+
|
|
3208
|
+
`akanjs` 3.0.0-beta.19 shipped 65 files (SQLite databases and logs, 820 KB) and `@akanjs/devkit` 2 from the
|
|
3209
|
+
gitignored `local/` directory the test suites write into, because both builds copied the package directory whole.
|
|
3210
|
+
`build-package` and `akanjs`'s own build now leave `local/` out, and `verify-akan-publish-packages` refuses a package
|
|
3211
|
+
whose pack list still contains it.
|
|
3212
|
+
|
|
3213
|
+
- 5f53462: `pathGet` reads every path `pathSet` can write
|
|
3214
|
+
|
|
3215
|
+
The two halves of the dotted-path vocabulary had drifted apart, and only the reading half was short. `pathSet`
|
|
3216
|
+
parsed segments with `/[^.[\]]+/g` and read containers through a `Map` branch; `pathGet` did `path.split(".")` and
|
|
3217
|
+
a bare property access. So `cutFrames[2].content` — the bracket spelling a form field hands `writeOn<Model>` — was
|
|
3218
|
+
three segments to a write and one to a read, and a `field(Map, …)` entry could be written and never read back. The
|
|
3219
|
+
failure is silent in both cases: `pathGet` answers with its fallback, which is `null`.
|
|
3220
|
+
|
|
3221
|
+
Both now share one definition, `toPathSegments`, so a path that writes and a path that reads cannot disagree about
|
|
3222
|
+
what its segments are, and `pathGet` reads a `Map` entry the way `pathSet` writes one. A caller that named its own
|
|
3223
|
+
separator keeps the plain split it has always had — under any separator but `.`, a `[0]` is part of a key rather
|
|
3224
|
+
than an index.
|
|
3225
|
+
|
|
3226
|
+
The argument order is left alone and remains the trap it was: `pathGet(path, obj)` against `pathSet(obj, path,
|
|
3227
|
+
value)`. Flipping it would be caught by the typechecker rather than silently, and there are only two callers — but
|
|
3228
|
+
`pathGetLoose` takes the same `(path, obj)` order as `pathGet`, so flipping one of the pair would replace a trap
|
|
3229
|
+
between get and set with a trap between get and get. The reference documentation's own example had the arguments
|
|
3230
|
+
backwards and is fixed here, which is the evidence that this one is worth deciding rather than leaving implicit.
|
|
3231
|
+
|
|
3232
|
+
- c45ac6c: perf(build): precompress the CSS bundle, and serve brotli sidecars ahead of gzip
|
|
3233
|
+
|
|
3234
|
+
The compiled stylesheet went out uncompressed in production. `precompressArtifacts` only walked
|
|
3235
|
+
`.akan/artifact/client`, so every JS chunk got a `.gz` sidecar while the CSS — written to
|
|
3236
|
+
`.akan/artifact/styles` — got none, and `#fileResponse` fell through to streaming the raw file. Nothing was
|
|
3237
|
+
wrong on the serving side: `#isCompressible` already accepts `text/css` and the sidecar lookup already ran.
|
|
3238
|
+
The file simply was not there. On one deployed app that was 319KB on the wire per cold load, against 45KB
|
|
3239
|
+
gzipped. The CSS is and always was minified — Lightning CSS runs in `prepareCssAsset` — so this is purely the
|
|
3240
|
+
transfer encoding.
|
|
3241
|
+
|
|
3242
|
+
Sidecars are now written for `styles/` too, and a `.br` sidecar is written alongside every `.gz`.
|
|
3243
|
+
|
|
3244
|
+
Brotli is tried first and gzip remains the fallback, because browsers only advertise `br` on secure origins.
|
|
3245
|
+
Quality is 11 for CSS and 9 for everything else: there is one CSS asset per basePath and it is the largest
|
|
3246
|
+
single file an app ships, worth ~20% over gzip for ~0.2s, while spending 11 on several hundred JS chunks costs
|
|
3247
|
+
~12s of build time for a few hundred KB. Measured on `apps/akan`: 371 files, 14.5MB raw, 4.2MB gzip, 3.9MB
|
|
3248
|
+
brotli, and the CSS bundle 103KB raw → 16.2KB gzip → 13.0KB brotli. The compress phase went from ~0.3s to
|
|
3249
|
+
~0.64s. Gzip sidecars moved to level 9, which is free at build time and was previously left at the default.
|
|
3250
|
+
|
|
3251
|
+
Encoding negotiation moved out of `WebRouter` and `AkanApp` into `resolveEncodedSidecar`. Both classes carried
|
|
3252
|
+
their own copy of the gzip lookup plus `#acceptsGzip` / `#isCompressible`, and a gateway that disagrees with
|
|
3253
|
+
its child about which encoding a request accepts serves a body the caller cannot decode. The shared version
|
|
3254
|
+
also honours `q=0` as the refusal it is, and no longer treats a token merely starting with `br` as brotli.
|
|
3255
|
+
|
|
3256
|
+
**Anything caching in front of the app must pass `Vary: Accept-Encoding` through.** The header was already
|
|
3257
|
+
set, but it only mattered when one encoding was on offer; with two, a proxy that drops or ignores it will
|
|
3258
|
+
hand a brotli body to a client that asked for gzip. Artifacts now carry both sidecars, so a build's static
|
|
3259
|
+
output grows by roughly the brotli total (3.9MB for `apps/akan`).
|
|
3260
|
+
|
|
3261
|
+
- f882bbc: Keep a projected field at its declared type instead of guessing it from the value.
|
|
3262
|
+
|
|
3263
|
+
Reading with a `select` — which is also every read of a model that carries a `field.secret`, since those force the
|
|
3264
|
+
projection path — decoded each column by looking at the string it came back as: a value starting with `{` or `[`
|
|
3265
|
+
was `JSON.parse`d, everything else passed through. That is a guess about the field's type made from its data, so
|
|
3266
|
+
the same `field(String)` answered as a string most of the time and as an object whenever a caller happened to
|
|
3267
|
+
store JSON in it. `genImage.prompt` is `field.secret(String)`, so an agent-written prompt that was valid JSON
|
|
3268
|
+
came back as an object and went out to the provider as one.
|
|
3269
|
+
|
|
3270
|
+
The cause is `json_extract`, which unwraps a JSON scalar into a SQL value and so erases the difference between
|
|
3271
|
+
the string `'{"a":1}'` and the object it spells — and returns `0`/`1` for a boolean, which the same path handed
|
|
3272
|
+
back as a number. Projection now selects with `->`, whose result is the value's JSON _text_, and parses that: the
|
|
3273
|
+
type comes from what was stored rather than from what it looks like. Postgres already returned typed jsonb and is
|
|
3274
|
+
left alone; only the heuristic that ran after it is gone.
|
|
3275
|
+
|
|
3276
|
+
The non-projection read path was never affected — it decodes against `FIELD_META` — so this only changes reads
|
|
3277
|
+
that named a `select` or touched a model with a hidden or secret field.
|
|
3278
|
+
|
|
3279
|
+
- 2d8de1c: fix(ui): `Refresh` handles pull-to-refresh itself, and `react-simple-pull-to-refresh` is no longer a peer
|
|
3280
|
+
|
|
3281
|
+
`Refresh` loaded `react-simple-pull-to-refresh` through a dynamic import, which the bundler resolves at build time,
|
|
3282
|
+
so the optional peer was in fact required: a workspace that had not installed it failed its CSR build with
|
|
3283
|
+
`Could not resolve: "react-simple-pull-to-refresh"`. `Refresh` now tracks the touch gesture itself — same props,
|
|
3284
|
+
same spinner, refresh past 67px of pull, capped at 95px — and the package is gone from `akanjs`'s peer
|
|
3285
|
+
dependencies. An app that installed it only for `Refresh` can remove it.
|
|
3286
|
+
|
|
3287
|
+
- 2d8de1c: fix(signal): a `query` endpoint that declares a `.body()` argument is named in a warning at declaration
|
|
3288
|
+
|
|
3289
|
+
`fetch` sends a query as `GET` with its `.param` and `.search` arguments in the URL and no body, so a `.body()`
|
|
3290
|
+
argument on a query never arrives over HTTP — a required one fails every call, an optional one is always `null` —
|
|
3291
|
+
while an MCP call does deliver it. Behaviour is unchanged; `endpoint()` now logs one warning per such argument naming
|
|
3292
|
+
the endpoint and the argument, and pointing at `.search()` or a mutation.
|
|
3293
|
+
|
|
3294
|
+
- f882bbc: feat: `RecentTime` accepts a `relative` prop for customizing relative labels
|
|
3295
|
+
|
|
3296
|
+
Default `fromNow` is unchanged (`하루 전`). `"auto"` / `"always"` switch to `Intl.RelativeTimeFormat` (`어제` vs `1일 전`), and a function replaces the relative label entirely — `breakUnit` still decides when the absolute date takes over.
|
|
3297
|
+
|
|
3298
|
+
- ccf2ae2: fix(dev): a page loaded while a save's pages build lands no longer renders without the client components it names
|
|
3299
|
+
|
|
3300
|
+
- `akan start` drops a route's client build when a save invalidates it mid-build. The request that was waiting for
|
|
3301
|
+
that build rendered anyway, from a manifest missing the rows the build would have added, so the RSC payload carried
|
|
3302
|
+
an error row and the page came up blank. The route now builds again at the new generation before it renders.
|
|
3303
|
+
|
|
3304
|
+
- 2d8de1c: fix(server): run a route's head once per render, and not at all for a cached replay
|
|
3305
|
+
|
|
3306
|
+
The RSC worker resolved every matched route's head before deciding how to render, although only a partial-commit
|
|
3307
|
+
patch decision ever reads it. A full render therefore ran the head twice, and a replay from the RSC result cache
|
|
3308
|
+
ran it once, backend queries included. The head now runs for that decision only when partial commit is on, the
|
|
3309
|
+
navigation is a patch candidate and the page declares `rscPatchHeadSafe`; otherwise it runs once, inside the
|
|
3310
|
+
render. A replayed cached page runs no head at all, the same as its body: a head that would now throw `notFound()`
|
|
3311
|
+
or `redirect()` no longer pre-empts a cached replay until the entry expires or is invalidated.
|
|
3312
|
+
|
|
3313
|
+
- 5f53462: The API and websocket prefixes are configurable, and the browser follows them.
|
|
3314
|
+
|
|
3315
|
+
- `new AkanApp({ prefix, websocketPrefix })` in `main.ts` moves the replica's route table, the gateway's websocket
|
|
3316
|
+
upgrade, `robots.txt`, the locale redirect's API bypass and the OpenAPI `servers` URL — and, through the SSR
|
|
3317
|
+
bootstrap script, the `fetchClient` in every tab the server renders. `AkanApp` also accepts its options as the
|
|
3318
|
+
sole argument now, so the server path no longer has to be spelled out to pass one.
|
|
3319
|
+
- `api: { prefix, websocketPrefix }` in `akan.config.ts` is the build's answer, for the bundles a server never
|
|
3320
|
+
gets to correct: a prebuilt CSR shell and a Capacitor bundle carry it inlined, and the generated Dockerfile
|
|
3321
|
+
writes it as the image default.
|
|
3322
|
+
- Resolution order is `globalThis.__AKAN_PREFIX__`, then `AKAN_API_PREFIX` / `AKAN_WS_PREFIX`, then
|
|
3323
|
+
`AKAN_PUBLIC_API_PREFIX` / `AKAN_PUBLIC_WS_PREFIX`, then `/api` and `/ws`. `getApiPrefix()` / `getWsPrefix()`
|
|
3324
|
+
from `akanjs/base` are the readers; `getEnv()` gains `apiPrefix` and `wsPrefix` beside them.
|
|
3325
|
+
- A prefix is a path segment: a blank value or a bare `/` is refused, and so is one whose first segment is a
|
|
3326
|
+
declared basePath, because either would silently trade the API's routes for the page routes or the reverse.
|
|
3327
|
+
`setPrefix` / `setWebsocketPrefix` now throw after `init()`, where the route table is built, instead of being
|
|
3328
|
+
quietly ignored.
|
|
3329
|
+
- `BlobStorage` derives its default URL prefix from the same value. Blob URLs written before a move stay pointing
|
|
3330
|
+
where they were written — they live on the rows that reference them.
|
|
3331
|
+
|
|
3332
|
+
- 5f53462: fix: runtime defects the reference audit turned up
|
|
3333
|
+
|
|
3334
|
+
- `imageOptimizer` re-encodes only JPEG, PNG, WebP and AVIF; a static GIF (or TIFF) that no accepted format beats is
|
|
3335
|
+
served as its own bytes instead of JPEG bytes labelled `image/gif`.
|
|
3336
|
+
- `DataList.sort()` sorts a copy, so the list it was called on keeps a consistent id lookup.
|
|
3337
|
+
- `fetch.view<Model>` exists whenever the model's `get` does, and `edit<Model>` when a create/update/remove endpoint
|
|
3338
|
+
does too; a read-only model used to have neither, and one without `get` had a `view` that always threw.
|
|
3339
|
+
`merge<Model>` still follows `update` alone.
|
|
3340
|
+
- Path parameters are `encodeURIComponent`-ed, so a `/` or `?` in a value no longer reroutes the request.
|
|
3341
|
+
- `ConstantRegistry.serialize` / `deserialize` handle a `Map` of a declared value type instead of throwing.
|
|
3342
|
+
- `BlobStorage` takes `appName` from the deployment env; the server env it was reading has none, so local blobs
|
|
3343
|
+
landed under `local/undefined/`.
|
|
3344
|
+
- UI: `Field.Switch` no longer marks itself optional; `Field.TextList` checks each entry against
|
|
3345
|
+
`minTextlength` / `maxTextlength` and caps the list at `maxlength`; `CsrLink` forwards its props and the caller's
|
|
3346
|
+
`onClick`; `CsrImage` / `Image` pass `alt` through; `useBottomUpTrans` measures height; the safe-area colour falls
|
|
3347
|
+
back to the default page state; `DataColumn.responsive` shows the column from `md` up.
|
|
3348
|
+
- webkit: `useFetch` follows the promise it is handed now rather than only the first one — pass a stable promise, or
|
|
3349
|
+
use `useFetchFn` — and `useCamera` no longer leaves an unhandled rejection in a browser without Capacitor.
|
|
3350
|
+
- `Constant.Doc` and its diagram are translated.
|
|
3351
|
+
|
|
3352
|
+
- 2d8de1c: fix(server): request, render and live-sync fixes from the server bug sweep
|
|
3353
|
+
|
|
3354
|
+
- **A multi-hop `x-forwarded-host` / `x-forwarded-proto` is read by its first value** wherever the server builds a
|
|
3355
|
+
public URL — the host→basePath map, the locale redirect and the rendered alternates. `pub, lb:8080` used to be
|
|
3356
|
+
taken whole, so the map never matched and redirects fell back to the internal origin.
|
|
3357
|
+
- **A client-side navigation renders with the locale and public path a page load of the same URL gets**, so on a
|
|
3358
|
+
sub-route host `usePage().path` no longer reads the internal basePath after a navigation.
|
|
3359
|
+
- **Gateway-served immutable assets carry `X-Content-Type-Options: nosniff` and `Referrer-Policy`**, like every
|
|
3360
|
+
response the web router sends. The gateway answers `/_akan/client|styles|fonts/*` itself under `akan start` and
|
|
3361
|
+
with two or more replicas.
|
|
3362
|
+
- **A respawned RSC worker whose init fails is replaced** instead of leaving every render queued behind it until
|
|
3363
|
+
the process restarts.
|
|
3364
|
+
- **A multi-field query loader keys each document unambiguously.** Values that concatenated alike (`x` + `yz` and
|
|
3365
|
+
`xy` + `z`) shared a key, and both callers in the batch got the later document.
|
|
3366
|
+
- **A live-sync room counts one join per socket.** A stray unsubscribe no longer drops a room other sockets are
|
|
3367
|
+
still in, and a socket that subscribed to the same room twice no longer leaves it behind when it closes.
|
|
3368
|
+
- **`/mcp` reads the bearer scheme case-insensitively and past repeated spaces** (RFC 7235, RFC 6750), so
|
|
3369
|
+
`bearer <token>` is judged like `Bearer <token>` instead of passing as no credential.
|
|
3370
|
+
|
|
3371
|
+
- 2d8de1c: fix(client): `router.push` / `replace` on the server warn that they do nothing, and name `router.redirect()`
|
|
3372
|
+
|
|
3373
|
+
Called during a server render, both only wrote an `info` line and returned — no navigation, no redirect — so a page
|
|
3374
|
+
that meant to send the visitor elsewhere rendered in place with nothing louder than a log line at the default level.
|
|
3375
|
+
They now log a warning that names `router.redirect()`, the call that answers a server render with a redirect. They
|
|
3376
|
+
still do not throw, so a page that renders today keeps rendering.
|
|
3377
|
+
|
|
3378
|
+
- 2d8de1c: fix(server): a backend finishes its shutdown and delivers its last log lines before it exits
|
|
3379
|
+
|
|
3380
|
+
- **The last log batch reaches the parent before a backend exits.** A message sent over IPC right before
|
|
3381
|
+
`process.exit` is lost from 8 KB on, so in `AKAN_LOG_FORMAT=ndjson` the shutdown lines and any final error stack
|
|
3382
|
+
never reached stdout. A backend now waits, up to one second, until that batch is delivered.
|
|
3383
|
+
- **A second `stop()` waits for the shutdown already under way.** It used to return at once, so its caller exited
|
|
3384
|
+
while the first shutdown was still closing services and the log transport — a second SIGINT, or every Ctrl-C
|
|
3385
|
+
under a gateway, where the terminal's signal and the gateway's `shutdown` message arrive together.
|
|
3386
|
+
- The ops snapshot route's 400 now states the id pattern it actually enforces (the first character must be a
|
|
3387
|
+
letter or digit).
|
|
3388
|
+
- An `AKAN_SUB_ROUTE_HOSTS` basePath that is ignored is warned about once per process instead of twice.
|
|
3389
|
+
- The builder-ready log line no longer prints `buildId=undefined`.
|
|
3390
|
+
|
|
3391
|
+
- f882bbc: Declare `client/sharedContext.ts` a client module, fixing a boot failure in 3.0.0-alpha.58.
|
|
3392
|
+
|
|
3393
|
+
Under the `react-server` exports condition `react` resolves to `react.react-server.js`, which exports no
|
|
3394
|
+
`createContext` and no stateful hook. The new helper was the one module in `client/` and `ui/` calling
|
|
3395
|
+
`createContext` without `"use client"`, so a server component importing anything from `akanjs/client` pulled it
|
|
3396
|
+
into the react-server graph and the app died at boot with `Export named 'createContext' not found`. Neither
|
|
3397
|
+
typecheck nor build reports it: both resolve `react` the ordinary way.
|
|
3398
|
+
|
|
3399
|
+
A test now fails if any module under `client/` or `ui/` imports a react binding the react-server condition does
|
|
3400
|
+
not provide — `createContext`, `useState`, `useEffect`, `useLayoutEffect`, `useContext`, `useRef`, `useReducer`,
|
|
3401
|
+
`useSyncExternalStore`, `useImperativeHandle` — without declaring the directive. `useMemo`, `useCallback`,
|
|
3402
|
+
`useId`, `createElement` and `Fragment` are provided there and stay unflagged, so a server component keeps using
|
|
3403
|
+
them.
|
|
3404
|
+
|
|
3405
|
+
- f882bbc: Render a `Map` field in the API explorer instead of crashing the page.
|
|
3406
|
+
|
|
3407
|
+
A Map field's `modelRef` is the `Map` constructor itself — it belongs to no registry, and the value type lives in
|
|
3408
|
+
`of`. `Signal.Object.Detail` handed that constructor to `ConstantRegistry.getModelName`, which threw
|
|
3409
|
+
`No ref name for modelRef: function Map()`, so every model carrying one took down `<Signal.Doc.Zone />` on render.
|
|
3410
|
+
It now labels the column `Map` and leaves the existing `⇒ <value type>` beside it.
|
|
3411
|
+
|
|
3412
|
+
The request and response examples had the same blind spot one layer down: they walked `Map[FIELD_META]`, which is
|
|
3413
|
+
`undefined`, so an endpoint whose body or return carried a Map threw before the sample JSON was built. A Map now
|
|
3414
|
+
serializes into the example as the string-keyed object it is on the wire — `{ "key": <value example> }`.
|
|
3415
|
+
|
|
3416
|
+
- 2d8de1c: fix(signal): mark a slice's `.body()` deprecated — `fetch` never sends its argument
|
|
3417
|
+
|
|
3418
|
+
A slice is served as GET queries (its list and insight reads), and a GET carries no body: `fetch.*` sends only a
|
|
3419
|
+
slice's `.param` and `.search` arguments. A required `.body` argument therefore failed every call with a
|
|
3420
|
+
missing-value error, and an optional one always arrived as `null`. `.body()` still declares the argument exactly as
|
|
3421
|
+
before, so nothing changes at runtime; the editor now shows it as deprecated. Declare the argument with `.search`
|
|
3422
|
+
(or `.param`) instead.
|
|
3423
|
+
|
|
3424
|
+
- 5f53462: fix: `Load.Units` rehydrates when a route change swaps its `init` for one with different query args
|
|
3425
|
+
|
|
3426
|
+
A slice's store is one global bucket keyed by the slice name, so `ticketListInProject` is shared by every project.
|
|
3427
|
+
`Load.Units` latched hydration on a mount-scoped `useRef(false)` with `[]` effect deps, so the first `init` it saw
|
|
3428
|
+
was the only one it ever read. Navigating between two routes that render the same slice with different args — a
|
|
3429
|
+
project switch, an org switch, any `[id]` change under a shared tab layout — reuses the mounted instance, and the
|
|
3430
|
+
latch made it keep rendering the previous route's rows: the page fetched the right data on the server and threw it
|
|
3431
|
+
away on the client.
|
|
3432
|
+
|
|
3433
|
+
Hydration identity is now the init payload's `queryArgs`, matching what `Load.View` already does with the model
|
|
3434
|
+
id: `loaded` is true only while the hydrated args still equal the ones `init` carries, and the memo and both
|
|
3435
|
+
effects key off the same signature. Same args arriving again — a `router.refresh()`, a re-render — still hydrate
|
|
3436
|
+
once, so client-side page, sort, and filter state is not clobbered.
|
|
3437
|
+
|
|
3438
|
+
- 5f53462: A solo replica logs its boot at `info`, so a production container that runs the one traffic replica prints a line
|
|
3439
|
+
instead of leaving stdout empty until something goes wrong.
|
|
3440
|
+
|
|
3441
|
+
`b42b7246` demoted that line to `debug` while trimming dev boot output. In a gateway deployment it costs nothing —
|
|
3442
|
+
`AkanApp gateway is running on port …` is `info`, and `akan start` never takes the solo path — but
|
|
3443
|
+
`AKAN_REPLICA=0,0,1`, which is what the chart deploys, has no gateway, and every other line a solo boot passes
|
|
3444
|
+
through is `verbose`. With the image's `AKAN_LOG_TO_FILE=0` and canonical request lines off by default, a healthy
|
|
3445
|
+
pod logged nothing at all: `kubectl logs` on a running container came back empty, and a crash looked the same as a
|
|
3446
|
+
quiet boot.
|
|
3447
|
+
|
|
3448
|
+
- 5f53462: `Loading.Spin` takes the color and the size the caller gives it, instead of pinning both to the icon
|
|
3449
|
+
|
|
3450
|
+
The spinner hard-coded `text-primary/70` and `text-xl` **on the icon**, while `className` landed on the wrapper
|
|
3451
|
+
around it. So every `text-*` and every `size-*` a caller passed was a silent no-op — `cn` merged it into a class
|
|
3452
|
+
list the icon never read — and the worst case was a filled surface: a `<Badge variant="info">` renders
|
|
3453
|
+
`text-info-foreground`, the spinner inside it stayed `text-primary/70`, and green-on-cyan at L 70% against L 74%
|
|
3454
|
+
is a 1.10:1 contrast that leaves the badge's text legible and the spinner invisible.
|
|
3455
|
+
|
|
3456
|
+
Both now sit on the wrapper. The icon is drawn at `1em` in `currentColor`, so color and size cascade into it, and
|
|
3457
|
+
`className` — merged last — wins over either. `tone="current"` names no color at all, which is what a filled
|
|
3458
|
+
surface needs: the spinner inherits whatever foreground the badge or the button already set. `tone` defaults to
|
|
3459
|
+
`"primary"`, so nothing that exists renders differently, and `"muted"` is there for a secondary surface.
|
|
3460
|
+
|
|
3461
|
+
`size` also takes a number now, drawn as that many pixels, because the named steps were the only vocabulary and a
|
|
3462
|
+
caller reaching past them wrote `size-[50px]` — a 50px empty box around a 20px icon. A custom `indicator` is left
|
|
3463
|
+
alone by `tone` exactly as before: it carries its own color, and the rotation is the wrapper's.
|
|
3464
|
+
|
|
3465
|
+
- ccf2ae2: feat(dev): SSR pages load their client code from a dev module registry
|
|
3466
|
+
|
|
3467
|
+
- Under `akan start`, an SSR page's `"use client"` code now comes from a module registry under
|
|
3468
|
+
`.akan/artifact/ssr-dev`, the same machinery the dev CSR registry uses, instead of the route chunks. React and the
|
|
3469
|
+
akanjs vendor facets stay the import map's, so the page keeps one React and one store registry. The route chunks and
|
|
3470
|
+
the `client-refresh` that re-imported all of them are gone from dev; `akan build` is unchanged.
|
|
3471
|
+
- A save patches the changed module in place with React Fast Refresh: state stays, and on minimal a component edit
|
|
3472
|
+
shows in about 110ms instead of 900ms. Undoing an edit now shows too; with chunks the browser kept the module it had
|
|
3473
|
+
already imported for that URL. A client module the RSC payload names that also exports constants re-runs in place.
|
|
3474
|
+
- A save that changes nothing the server renders (a `"use client"` module that keeps its export names, or a module only
|
|
3475
|
+
client code imports) keeps the page's build id and refetches no RSC payload. One that does — a component both sides
|
|
3476
|
+
render, a constant — holds its client patch until that save's pages build lands, then sends the patch and the RSC
|
|
3477
|
+
refresh together, so the page never runs new client code against old server props. A constant edit that reloads
|
|
3478
|
+
waits for that build too, and a backend restarted by a server-only save renders from the latest pages build.
|
|
3479
|
+
- The registry builds once in a build worker after the dev server boots, beside the builder's slow lane, and each route
|
|
3480
|
+
build adds the entries its page names that the registry lacks; a page served before the registry exists waits for
|
|
3481
|
+
it in the browser, in short holds. Route builds no longer bundle browser chunks in dev, which took about 240MB off
|
|
3482
|
+
the builder's peak on apps/akan.
|
|
3483
|
+
- Most saves are patched in the resident builder: a new file, a new import from a package something already resolved,
|
|
3484
|
+
a moved or renamed file (a case-only rename, `Foo.tsx` to `Foo/index.tsx`, a file created beside a folder an import
|
|
3485
|
+
resolved to), a deleted module brought back. A whole build in a build worker is left for a new npm package, a signal
|
|
3486
|
+
save (its metadata is inlined into the client runtime; a dictionary save does not rebuild the SSR registry, which
|
|
3487
|
+
inlines none), and a registry whose last whole build was cut short. A save that does not compile — a typo in an
|
|
3488
|
+
import included — is reported by the builder at once, and the save that fixes it patches in about 120ms.
|
|
3489
|
+
- A failed build keeps the registry the tabs and the next save read. Its error stays on the overlay, is shown to a tab
|
|
3490
|
+
opened while it stands, and clears with the save that fixes it — for a route that failed on a shared module too,
|
|
3491
|
+
which is built again once a newer build goes green. A broken file no page imports any more stops failing saves.
|
|
3492
|
+
- A client module whose top level threw, or a store that throws at startup, recovers with the save that fixes it, and
|
|
3493
|
+
the dev server's error page carries the HMR client, so it reloads once the fix lands. A pages bundle that builds but
|
|
3494
|
+
throws while it loads shows its error while the RSC worker keeps serving the bundle it had.
|
|
3495
|
+
- A tab that reconnects after a backend restart loads the patches it missed and refetches its payload instead of
|
|
3496
|
+
reloading; one that reconnects to a new dev session (a new `akan start`, a config restart) reloads onto its registry.
|
|
3497
|
+
A tab that booted beside the vendor file of the build before loads the newer one, so a new npm import in a leaf
|
|
3498
|
+
component works without a manual refresh.
|
|
3499
|
+
- A build worker exits with the builder that spawned it, and a boot build whose worker was killed is retried after 10
|
|
3500
|
+
seconds. On Windows, registry writes retry a rename refused while a reader holds the file, and a build worker hashes
|
|
3501
|
+
the same bundle config as the builder, so saves are not all handed to whole builds.
|
|
3502
|
+
|
|
3503
|
+
- 2d8de1c: fix(server): a client-side navigation on a sub-route host stays inside that host's basePath
|
|
3504
|
+
|
|
3505
|
+
On a host mapped to a basePath (`subRoutes` or `AKAN_SUB_ROUTE_HOSTS`), a page load rewrites every path into that
|
|
3506
|
+
basePath, so a path only another basePath serves is a 404 there. A client-side navigation (`<Link>`,
|
|
3507
|
+
`router.push`) also tried the other basePaths and rendered their pages under the mapped host's domain: on the
|
|
3508
|
+
`soft` host, `/en/admin` and `/en/office/admin` both showed `office`'s admin page. The navigation now resolves its
|
|
3509
|
+
target exactly as a page load on that host does, so the two answer the same page, or both a 404. A host with no
|
|
3510
|
+
basePath of its own (a debug host, `akan start` on localhost) still finds the basePath whose route matches.
|
|
3511
|
+
|
|
3512
|
+
- 69f7178: fix(service): let `onInit` and `onDestroy` be declared sync
|
|
3513
|
+
|
|
3514
|
+
`Adaptor` and `Service` both declared the pair as `onInit(): Promise<void>`, so a hook with nothing to await was a
|
|
3515
|
+
type error even though every call site already awaited it:
|
|
3516
|
+
|
|
3517
|
+
```
|
|
3518
|
+
Property 'onInit' in type 'SyncAdaptor' is not assignable to the same property in base type '… & Adaptor'.
|
|
3519
|
+
```
|
|
3520
|
+
|
|
3521
|
+
Both hooks — and the interceptor's pair, which is the same shape — now return `Promise<void> | void`, the same
|
|
3522
|
+
spelling `_preRemove` and the DI's own `onDestroy` probe already use. `override async onInit()` is unchanged.
|
|
3523
|
+
|
|
3524
|
+
```ts
|
|
3525
|
+
export class Cursor extends adapt("cursor" as const, () => ({})) {
|
|
3526
|
+
override onInit() {
|
|
3527
|
+
this.watcher = watch(this.root);
|
|
3528
|
+
}
|
|
3529
|
+
override onDestroy() {
|
|
3530
|
+
this.watcher?.close();
|
|
3531
|
+
}
|
|
3532
|
+
}
|
|
3533
|
+
```
|
|
3534
|
+
|
|
3535
|
+
The default no-op bodies stopped being `async` as part of the same change: `serve()`'s implementation returns the
|
|
3536
|
+
raw class, and TypeScript checks each overload's return type against it in one direction or the other, so widening
|
|
3537
|
+
the interface while the base stayed `async` left neither direction assignable and broke `serve` itself with TS2394.
|
|
3538
|
+
The one observable consequence is that an un-overridden `onInit()` returns `undefined` rather than a resolved
|
|
3539
|
+
promise — `await` reads both the same, but `.then()` on it does not.
|
|
3540
|
+
|
|
3541
|
+
`_libsOnInit` / `_libsOnDestroy` now wrap each hook call. A sync hook that throws used to escape the `.map` before
|
|
3542
|
+
`Promise.all` was ever reached, stranding the promises the async hooks beside it had already started; the throw
|
|
3543
|
+
now arrives as a rejection with every sibling still attached.
|
|
3544
|
+
|
|
3545
|
+
- 5f53462: feat: the agent's pointer lives for the turn, and waits out the model's own thinking
|
|
3546
|
+
|
|
3547
|
+
The pointer's life was measured in the gap between **calls** — 1.4 seconds of quiet and it faded. But a model's
|
|
3548
|
+
calls arrive with its own writing between them, which takes several seconds, so a turn of four calls was four
|
|
3549
|
+
pointers: each one appeared, pressed, and vanished before the next arrived. The unit was wrong.
|
|
3550
|
+
|
|
3551
|
+
`AgentSession` now reports the boundary that matters. `onTurn(running)` fires once when a turn starts and once
|
|
3552
|
+
when it settles, and only the conversation loop reports there: `compact` runs under the same internal flag and
|
|
3553
|
+
drives nothing on screen, so a host drawing the agent at work would otherwise draw it for a summary nobody asked
|
|
3554
|
+
to watch. Between the calls of one turn the pointer stays where it last landed and turns into a spinner, because a
|
|
3555
|
+
pointer sitting perfectly still for four seconds reads as stuck rather than as waiting.
|
|
3556
|
+
|
|
3557
|
+
It still appears only at the first control it presses, and **a turn that drove no control draws no pointer at
|
|
3558
|
+
all** — parking one in a corner for a turn that only answered a question would be the screen claiming something
|
|
3559
|
+
that did not happen.
|
|
3560
|
+
|
|
3561
|
+
While a reveal scrolls the page to the next control, the pointer holds still the way a person's does and carries a
|
|
3562
|
+
chevron pointing the way the view is travelling — stillness over a sliding page otherwise reads as a pointer that
|
|
3563
|
+
has come loose rather than as the one doing the scrolling. The scroll budget is now spent per turn as well, instead
|
|
3564
|
+
of per quiet second, which is what it was always guessing at.
|
|
3565
|
+
|
|
3566
|
+
**`navigate` draws again, but only on an element.** The link a navigation is going to, when exactly one visible
|
|
3567
|
+
link goes there, is pressed before the route moves — which needs the call to wait, so `ToolRunner` now awaits the
|
|
3568
|
+
`start` activity and `AgentSession.onActivity` may answer a promise. `AgentVisual` returns one for that call alone
|
|
3569
|
+
and caps it at 600ms; everything else still draws while the call is already running. An off-screen link is left
|
|
3570
|
+
alone, because scrolling to a link and then leaving the page it is on is two motions for one act. **Link presence
|
|
3571
|
+
decides what is drawn, never what is allowed**: the agent may still navigate anywhere the user could type, and a
|
|
3572
|
+
zone that must stay on one screen drops `navigate` through `builtins` as before.
|
|
3573
|
+
|
|
3574
|
+
`Router.routeOf(pathname)` is what compares the two addresses — the locale and base-path segments live on the
|
|
3575
|
+
`<a href>` and never on the tool argument. It is `getPath` without the browser guard, which `getPath` only needs
|
|
3576
|
+
for its own default argument.
|
|
3577
|
+
|
|
3578
|
+
**The pointer clears the control before it waits.** A spinner parked on the button it just pressed reads as one
|
|
3579
|
+
stuck to it, and it covers the very change the press caused — a person clicks and takes the hand away. It drifts
|
|
3580
|
+
just past the control's box, down and to the right unless the viewport edge is there, and spins from that spot.
|
|
3581
|
+
|
|
3582
|
+
**A control the screen is not actually showing is not pointed at, and the pointer goes rather than travelling to
|
|
3583
|
+
it.** `checkVisibility` answers about an element alone, so one under a modal's backdrop, inside a drawer that has
|
|
3584
|
+
slid off, or faded to nothing all pass it while being invisible to the user — and a pointer sent there lands on a
|
|
3585
|
+
blank patch of overlay, which reads as the effect being broken rather than as the agent acting. The test is
|
|
3586
|
+
`elementFromPoint` at the control's own centre, taken after the reveal has settled, with an ancestor counting as a
|
|
3587
|
+
hit so a `<label>` wrapping its input is not mistaken for an occlusion.
|
|
3588
|
+
|
|
3589
|
+
**`agentAttrs(handler, key)` tells namesakes apart.** One tool serves a whole tab strip or a whole list, so every
|
|
3590
|
+
control carrying it was interchangeable in the DOM and the no-guessing rule turned that into drawing nothing.
|
|
3591
|
+
A key — in the same vocabulary the call's argument uses — lets the pointer pick the one the call named; short of
|
|
3592
|
+
exactly one answer nothing is still drawn. `Tab.Menu` now passes its own `menu` key, so switching a tab is drawn
|
|
3593
|
+
on the menu that was switched to.
|
|
3594
|
+
|
|
3595
|
+
`reveal` and `cursor` are now independent, as their names always claimed: `visual={{ reveal: false }}` keeps the
|
|
3596
|
+
pointer and drops the ring, where before it dropped both.
|
|
3597
|
+
|
|
3598
|
+
- 2d8de1c: fix(tunnel): a shared app keeps answering after its websockets close
|
|
3599
|
+
|
|
3600
|
+
A finished websocket stream closed its data socket, but the tunnel agent kept counting that socket as idle, so it
|
|
3601
|
+
never opened a replacement. Every page reload closes its HMR socket, so an `akan start --share` tunnel with the
|
|
3602
|
+
default four idle sockets stopped answering after about five reloads, until the control link reconnected. A
|
|
3603
|
+
finished stream now retires its socket and the pool refills it.
|
|
3604
|
+
|
|
3605
|
+
A raw TCP stream also ended in a stack overflow (the stream and its socket closed each other in a loop), and it
|
|
3606
|
+
forwarded a fast origin's output without waiting for the tunnel socket to drain. Neither path is opened by the
|
|
3607
|
+
current wire protocol; both are fixed ahead of it.
|
|
3608
|
+
|
|
3609
|
+
- 2d8de1c: fix(tunnel): a websocket stream the gateway resets gives its data socket back
|
|
3610
|
+
|
|
3611
|
+
When the gateway reset a tunnelled websocket, the agent closed the local websocket but left the data socket that
|
|
3612
|
+
carried it open. The wire contract never returns a raw stream's socket to the pool, so the gateway could not use it
|
|
3613
|
+
again, while the agent counted it as idle and opened no replacement: every reset shrank the working pool by one.
|
|
3614
|
+
The agent now closes that data socket, as it already did when a websocket or TCP stream ends on its own side or the
|
|
3615
|
+
gateway resets a TCP stream, and opens a fresh one in its place.
|
|
3616
|
+
|
|
3617
|
+
- 2d8de1c: fix(tunnel): a tunnelled TCP stream half-closes instead of hanging up
|
|
3618
|
+
|
|
3619
|
+
The wire contract ends each direction of a stream on its own, but the agent's raw TCP stream treated the
|
|
3620
|
+
gateway's `end` as the end of everything. It closed the connection to the local port outright, so whatever the
|
|
3621
|
+
origin answered after the caller's end of input was lost, and bytes the caller sent after the origin finished its
|
|
3622
|
+
own side were dropped too. The agent now shuts down only the direction that ended: the origin sees end of input and
|
|
3623
|
+
can still answer, and the caller can keep sending after the origin's end. The data socket goes back once both
|
|
3624
|
+
directions are done or the stream is reset. The local connection is dialled through `node:net`, because
|
|
3625
|
+
`Bun.connect` stops reading once its write side is shut down (Bun 1.4). Wire version 1 opens no TCP stream yet, so
|
|
3626
|
+
no running tunnel behaves differently today.
|
|
3627
|
+
|
|
3628
|
+
- 2d8de1c: fix(tunnel): a tunnelled websocket stream's `run()` finishes however the stream ends
|
|
3629
|
+
|
|
3630
|
+
`TunnelWebsocketStream.run()` (`akanjs/server/tunnel`) resolved only when the local websocket closed or failed on
|
|
3631
|
+
its own. When the stream was reset instead — by the gateway, by the gateway dropping the data socket under it, or by
|
|
3632
|
+
the agent stopping — the promise never settled, unlike the http and tcp streams. It now resolves once on every path,
|
|
3633
|
+
and a reset that arrives after the stream has already ended releases the data socket no second time. The agent
|
|
3634
|
+
itself never awaited it, so a tunnel behaves the same.
|
|
3635
|
+
|
|
3636
|
+
- 5f53462: fix: every store write marks the slice payloads stale, and `Load.View` refetches a replayed one
|
|
3637
|
+
|
|
3638
|
+
A write patches the lists and the model the store holds, but the RSC payload each of them was hydrated from still
|
|
3639
|
+
carries the pre-write rows — and `rscCache` replays that payload verbatim when the user navigates back to a route
|
|
3640
|
+
they already visited. So changing a ticket's status in one project, switching projects, and coming back showed the
|
|
3641
|
+
status the page was first rendered with.
|
|
3642
|
+
|
|
3643
|
+
`staleAtOfOtherSlices` was the only guard against this and it was half-wired: it ran on `create<Model>` /
|
|
3644
|
+
`create<Model>InForm` only, and it excluded the slice that issued the write. `update<Model>`,
|
|
3645
|
+
`update<Model>InForm`, `merge<Model>`, `remove<Model>` and `set<Model>` — the last of which is where every custom
|
|
3646
|
+
mutation endpoint commits its result — stamped nothing at all.
|
|
3647
|
+
|
|
3648
|
+
It is now `staleAtOfSlices`, stamped by all seven writes across every slice including the issuing one. The
|
|
3649
|
+
exclusion bought nothing: the stamp starts no fetch, because `Load.Units` only re-checks staleness when a list
|
|
3650
|
+
re-hydrates, and the slice that took the optimistic patch is exactly as far behind in its payload as its siblings.
|
|
3651
|
+
|
|
3652
|
+
`Load.View` had the same hole with no guard at all — view a model, edit it, view another, come back — and now
|
|
3653
|
+
refetches through `view<Model>` when the payload it is re-hydrating from predates the last local write. It reads
|
|
3654
|
+
`<refName>StaleAt`, the root slice's stamp, and does nothing on a model whose signal declares no slice.
|
|
3655
|
+
|
|
3656
|
+
Both checks compare a server-generated `initAt`/`viewAt` against a client-generated `staleAt`, so a badly skewed
|
|
3657
|
+
client clock costs an extra refetch per navigation (clock ahead) or misses one (clock behind). That trade is
|
|
3658
|
+
unchanged from the create path this generalizes.
|
|
3659
|
+
|
|
3660
|
+
- f882bbc: Rename a zone's scope-prefixed tools onto what a provider's function-calling wire accepts.
|
|
3661
|
+
|
|
3662
|
+
A zone publishes `<id>.<name>`, which is legal for MCP but rejected by every OpenAI-compatible and Anthropic
|
|
3663
|
+
function schema — those allow `[A-Za-z0-9_-]` only, up to 64 characters. The name went to the provider verbatim.
|
|
3664
|
+
A validating provider answers 400; DeepSeek did not, and what happened instead was harder to find: the model
|
|
3665
|
+
normalized the illegal name itself, called the bare tool, and the browser answered `Unknown tool` for a tool that
|
|
3666
|
+
was published all along, spending a turn.
|
|
3667
|
+
|
|
3668
|
+
`AgentService.runTurn` now renames every tool name in the request — the published tools and the calls the
|
|
3669
|
+
transcript still carries — and reads the answer back under the name the surface registered. A request whose names
|
|
3670
|
+
already fit is handed on as the same object, so the root agent's turn is unchanged. `Agent.Context`'s Assemble
|
|
3671
|
+
now lists the published tool names, which is where a prefix mismatch is visible in one glance.
|
|
3672
|
+
|
|
3
3673
|
## 2.4.2
|
|
4
3674
|
|
|
5
3675
|
### Minor Changes
|