@akanjs/cli 3.0.2 → 3.1.0-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/.build-stamp +1 -1
- package/{CodeAgent-szbjv4gv.js → CodeAgent-q7a4srt6.js} +2 -2
- package/{CodeTui-5nw86ab6.js → CodeTui-ak23dxc9.js} +1 -1
- package/{TunnelShare-cwh9ezsm.js → TunnelShare-21mm6dr5.js} +2 -2
- package/{agent.command-np1dffkb.js → agent.command-f3gsc1gd.js} +5 -5
- package/akanCode.js +2 -2
- package/{application.command-gk69v1xs.js → application.command-3v2d53gd.js} +37 -15
- package/{applicationBuildRunner-wx09n9g6.js → applicationBuildRunner-1yaqsrdq.js} +115 -103
- package/buildBatch.proc.js +16 -5
- package/cloud.command-kk2rwkvw.js +187 -0
- package/cloud.script-r7eyfey4.js +20 -0
- package/{code.command-hmtx14b2.js → code.command-30sca09d.js} +6 -6
- package/commandManifest.json +1 -1
- package/{context.command-6aqvdx43.js → context.command-k7eqacq6.js} +13 -13
- package/context.runner-hqd1ppqe.js +21 -0
- package/{guideline.command-axvf68gc.js → guideline.command-dtczrqpf.js} +2 -2
- package/guidelines/agentRule/agentRule.instruction.md +10 -2
- package/guidelines/conventions/conventions.instruction.md +68 -25
- package/guidelines/cssRule/cssRule.instruction.md +19 -0
- package/guidelines/mcpRule/mcpRule.instruction.md +67 -12
- package/guidelines/queryRule/queryRule.instruction.md +11 -1
- package/guidelines/runtimeRule/runtimeRule.instruction.md +97 -16
- package/guidelines/ssrRule/ssrRule.instruction.md +10 -7
- package/guidelines/transportRule/transportRule.instruction.md +19 -0
- package/guidelines/workspaceRecipes/workspaceRecipes.instruction.md +6 -6
- package/incrementalBuilder.proc.js +24 -3
- package/{index-h9ht6s60.js → index-05g3jayg.js} +1 -1
- package/{index-j38qkvkk.js → index-0bm04n5g.js} +12 -10
- package/{index-g38nhv87.js → index-2etcv0sc.js} +1 -1
- package/{index-zyw622y1.js → index-2mx692qq.js} +3 -3
- package/{index-jb0hmt3k.js → index-2t4tqd1c.js} +3 -3
- package/{index-50wb2a26.js → index-55mynnqs.js} +354 -129
- package/{index-q6ft9fkc.js → index-6jjtnhkw.js} +8 -3
- package/index-6w0rbr9d.js +68 -0
- package/{index-2wc41k5w.js → index-7bymavqk.js} +2 -2
- package/{index-gtjgnv36.js → index-aprt7pkz.js} +6 -9
- package/{index-1bjkfvm9.js → index-c1x0be5j.js} +2 -2
- package/{index-z750gk1j.js → index-c2yrsxdh.js} +13 -7
- package/{index-cb6yc5mb.js → index-c76k723e.js} +2 -2
- package/{index-6keyf4sg.js → index-ctanya41.js} +4 -4
- package/{index-f4e08ptp.js → index-e0cvze1s.js} +4 -4
- package/{index-hp3stk1m.js → index-fm46xrf1.js} +3 -3
- package/{index-3xmzr1p1.js → index-gnyf1fqy.js} +2 -2
- package/{index-zwd84m0w.js → index-jyv387qw.js} +4 -4
- package/{index-jk9xnck6.js → index-kjvrq3nc.js} +231 -133
- package/{index-xwprn9nr.js → index-nz15k0b6.js} +1 -1
- package/{index-nwd6qzvv.js → index-p9ystjrp.js} +138 -55
- package/index-q958tqqg.js +77 -0
- package/{index-50jjwg8y.js → index-qt22xstj.js} +1 -1
- package/{index-94s080qm.js → index-r2tpeb3n.js} +127 -1
- package/{index-bsv901fd.js → index-rwq3ekqc.js} +7 -7
- package/{index-t91a7339.js → index-tn4rpqp1.js} +96 -12
- package/{index-1gs9g12n.js → index-v29746vb.js} +4 -4
- package/index-ww9bhp67.js +62 -0
- package/{index-hxnnfy57.js → index-y8q31011.js} +180 -8
- package/index.js +22 -21
- package/{library.command-9m2rdwwz.js → library.command-gaga5bmq.js} +3 -3
- package/{localRegistry.command-kda81jyk.js → localRegistry.command-9e84e32q.js} +9 -9
- package/{module.command-q63dffya.js → module.command-7vpf6db4.js} +5 -5
- package/{package.command-y2jh0m99.js → package.command-twmvvf69.js} +3 -3
- package/package.json +2 -2
- package/{page.command-f3e4cwhh.js → page.command-5asq7fdf.js} +3 -3
- package/pipeline.command-d0v38572.js +1334 -0
- package/{primitive.command-r4kq75qj.js → primitive.command-dyrs2fm8.js} +6 -6
- package/projectTypecheck-cvtkrzp2.js +7 -0
- package/{quality.command-f7gzh26z.js → quality.command-6emsqpbg.js} +5 -4
- package/{repair.command-0qmyr6qa.js → repair.command-8t6aegv5.js} +5 -5
- package/{scalar.command-kddn7av4.js → scalar.command-bnggy1x7.js} +4 -4
- package/{subspace.command-ft65qvc4.js → subspace.command-f02j5tge.js} +158 -91
- package/templates/appSample/page/task/[taskId]/_index.tsx +2 -2
- package/templates/appSample/page/task/[taskId]/edit.tsx +2 -2
- package/templates/appSample/page/task/_index.tsx +2 -2
- package/templates/crudPages/[__model__Id]/edit/page.tsx +2 -2
- package/templates/crudPages/[__model__Id]/page.tsx +3 -3
- package/templates/crudPages/page.tsx +2 -2
- package/templates/crudSinglePage/page.tsx +3 -3
- package/templates/lib/cnst.ts +2 -2
- package/templates/lib/sig.ts +3 -3
- package/{tunnel.command-b3ymdk4f.js → tunnel.command-9x8nfn75.js} +16 -13
- package/{workflow.command-z1srcdfw.js → workflow.command-jx0wag5r.js} +10 -10
- package/{workspace.command-vp6vmx3z.js → workspace.command-cmj6mqyk.js} +57 -27
- package/cloud.command-tw06613r.js +0 -95
- package/cloud.script-64gzba8r.js +0 -20
- package/context.runner-hnkvv3z3.js +0 -21
|
@@ -62,6 +62,25 @@ Screen code is invariant — a plain `<Button variant="primary">` never changes.
|
|
|
62
62
|
- Theme-following colors still belong to the app: a lib token is for a color that must **not** change with `data-theme`.
|
|
63
63
|
- An `@import` the CSS pipeline cannot resolve fails the build; it is never dropped. A stylesheet under `page/` that no route imports is compiled by nothing and warns at build time.
|
|
64
64
|
|
|
65
|
+
## Frame Variables (the page's chrome and the device's insets)
|
|
66
|
+
|
|
67
|
+
The frame writes these on `#frameRoot` and `<html>`, so any class can read them, web and native alike:
|
|
68
|
+
|
|
69
|
+
- `--akan-top-safe-area` / `--akan-bottom-safe-area` — the notch and the home indicator. On the web they are
|
|
70
|
+
`env(safe-area-inset-*)` where the route chain declares `safeArea` (`.config({ safeArea: true })` on the root
|
|
71
|
+
layout) and `0px` where it does not; in a native app they are the shell's inset. Use the variable alone: never
|
|
72
|
+
write `env(safe-area-inset-*)` beside it, and never subtract one from the other. The frame paints the bottom strip
|
|
73
|
+
only under bottom chrome (a `BottomInset` or `BottomTab`) or a route's own `bottomSafeAreaColor`; elsewhere a page's
|
|
74
|
+
`fixed bottom-0` bar paints its own safe-area padding.
|
|
75
|
+
- `--akan-top-inset` / `--akan-bottom-inset` — the height of the page's top bar and bottom tab slot, per breakpoint
|
|
76
|
+
when the page declares a responsive inset. A slot whose inset is 0 at a width clips its chrome there and hides it
|
|
77
|
+
from the keyboard and screen readers, so a phone-only bar needs no `md:hidden`.
|
|
78
|
+
- `--akan-keyboard-inset`, `--akan-viewport-height`, `--akan-viewport-offset-top` — the on-screen keyboard over the
|
|
79
|
+
page's bottom, the visible height, and how far iOS panned the page to show a field (the top slots follow it).
|
|
80
|
+
- `--akan-content-height` — the room between the insets. iOS pans under the keyboard instead of shrinking the page,
|
|
81
|
+
so a column sized by it leaves the panned-to area blank: keep a keyboard-heavy screen's input bar in flow at the end
|
|
82
|
+
of its column, or in the keyboard slot.
|
|
83
|
+
|
|
65
84
|
## Codegen Rules
|
|
66
85
|
- Do not hardcode hex or raw-palette colors (`bg-red-500`, `#ff0000`); use semantic tokens.
|
|
67
86
|
- Do not put inline color literals in `style={{ }}`; use token classes.
|
|
@@ -10,7 +10,8 @@ The always-loaded convention set carries the short version; this is the full con
|
|
|
10
10
|
Every signal is served to AI agents as an MCP server on `POST /mcp`. **`/mcp` is mounted by default and exposure
|
|
11
11
|
follows an endpoint's guards — there is no per-endpoint opt-in, and nothing to write in a signal file.** An endpoint
|
|
12
12
|
that declares a real guard is published; one that declares none is refused, and so is a mutation whose only guard is
|
|
13
|
-
`Public`. `AKAN_MCP=false` takes the whole surface off
|
|
13
|
+
`Public`. `AKAN_MCP=false` takes the whole surface off, and an edge server (`AKAN_PUBLIC_OPERATION_MODE=edge`) starts
|
|
14
|
+
with it off — `AKAN_MCP=true` or `setMcp(true)` puts it back. The reasoning is that the guards are already the
|
|
14
15
|
authorization decision and `filterForAccount` re-reads them per caller on every listing, so a second per-endpoint
|
|
15
16
|
switch says nothing the guards do not — while guaranteeing that every endpoint added later is invisible to agents
|
|
16
17
|
until somebody remembers it.
|
|
@@ -38,6 +39,9 @@ export class FileSlice extends slice(srv.file, {
|
|
|
38
39
|
`update`, `remove`), same fallbacks (`create`/`update`/`remove` inherit `cru`), and the same scope: it narrows
|
|
39
40
|
the root slice and the generated CRUD, and never reaches a named slice or a custom endpoint. Those declare
|
|
40
41
|
their own `mcp` beside their own `guards`. The bare `mcp: false` is every key at once, still within that scope.
|
|
42
|
+
- **A screen keeps reaching it.** To a host that renders MCP Apps views, an endpoint refused for `mcp: false` alone
|
|
43
|
+
is published with `visibility: ["app"]`: the view the page tool opens may call it, the model is never shown it
|
|
44
|
+
(below). Every other refusal still holds there.
|
|
41
45
|
- **`mcp: true` is the default and is a no-op**; write it only to say out loud that an entry is deliberately on
|
|
42
46
|
the shelf. It does not override a shape refusal (an `Any` return stays refused) and it cannot publish something
|
|
43
47
|
the guard rules keep out.
|
|
@@ -50,13 +54,14 @@ export class FileSlice extends slice(srv.file, {
|
|
|
50
54
|
|
|
51
55
|
## Configuration
|
|
52
56
|
Settings live in the app's `lib/option.ts` — `option.setMcp({ … })`, taking `enabled`, `readOnly`, `path`,
|
|
53
|
-
`version`, `instructions`, `allowedOrigins`, `pageSize`, `language`, `legacyTextBlock`, `outputSchema`, `rateLimit`,
|
|
57
|
+
`version`, `instructions`, `allowedOrigins`, `pageSize`, `language`, `legacyTextBlock`, `outputSchema`, `rateLimit`,
|
|
58
|
+
`promptBudget`, `apps`, `modern`, and `auth`. **Not `main.ts`**: the gateway
|
|
54
59
|
there only spawns children, and `option.ts` is the app-authored file `server.ts` already hands to the process that
|
|
55
60
|
mounts `/mcp`. Every lib's option is read in mount order with the app's last, so an app tightens what a library
|
|
56
61
|
declared without restating it. Each field also has an env spelling (`AKAN_MCP`, `AKAN_MCP_READONLY`,
|
|
57
62
|
`AKAN_MCP_PATH`, `AKAN_MCP_VERSION`, `AKAN_MCP_INSTRUCTIONS`, `AKAN_MCP_ALLOWED_ORIGINS`, `AKAN_MCP_PAGE_SIZE`,
|
|
58
63
|
`AKAN_MCP_LANGUAGE`, `AKAN_MCP_LEGACY_TEXT`, `AKAN_MCP_OUTPUT_SCHEMA`, `AKAN_MCP_RATE_LIMIT`, `AKAN_MCP_CONCURRENT`,
|
|
59
|
-
`AKAN_MCP_AUTH_SERVERS`, `AKAN_MCP_SCOPES`, `AKAN_MCP_RESOURCE`) for a deployment that configures what the source does
|
|
64
|
+
`AKAN_MCP_PROMPT_BUDGET`, `AKAN_MCP_APPS`, `AKAN_MCP_MODERN`, `AKAN_MCP_AUTH_SERVERS`, `AKAN_MCP_SCOPES`, `AKAN_MCP_RESOURCE`) for a deployment that configures what the source does
|
|
60
65
|
not, which the option overrides.
|
|
61
66
|
The two booleans answer to `AKAN_PUBLIC_MCP` / `AKAN_PUBLIC_MCP_READONLY` too, the same pairing `AKAN_OPENAPI`
|
|
62
67
|
has, and a value written in code wins over the env of the same name — an explicit `undefined` is not a value.
|
|
@@ -100,8 +105,8 @@ export class TaskEndpoint extends endpoint(srv.task, ({ mutation }) => ({
|
|
|
100
105
|
export default page()
|
|
101
106
|
.param("taskId", ID, { desc: "The task to review." })
|
|
102
107
|
.prompt("reviewTask", "Review this task and suggest next steps.")
|
|
103
|
-
.render(
|
|
104
|
-
const { taskView } =
|
|
108
|
+
.render(({ taskId }) => {
|
|
109
|
+
const { taskView } = fetch.viewTask(taskId);
|
|
105
110
|
return <Task.Zone.View view={taskView} />;
|
|
106
111
|
});
|
|
107
112
|
```
|
|
@@ -226,7 +231,9 @@ export default page()
|
|
|
226
231
|
wire-identical over the POST-only surface this implements — a client whose proposal is not listed is told to
|
|
227
232
|
disconnect. An unknown proposal is answered at whichever end of that list it is closer to, and an unimplemented
|
|
228
233
|
method answers `404` to a modern client but `200` to a legacy one, whose era spends `404` on "your session is
|
|
229
|
-
gone".
|
|
234
|
+
gone". **While MCP Apps renders views only the legacy pair is spoken**: `server/discover` answers `-32601` and
|
|
235
|
+
`initialize` settles on `2025-11-25`, because claude.ai renders a view only for a server it reached through
|
|
236
|
+
`initialize`. `modern: true` / `AKAN_MCP_MODERN=true` offers `2026-07-28` anyway; `false` withholds it everywhere.
|
|
230
237
|
- **A modern-era request mirrors `MCP-Protocol-Version` and `Mcp-Method` into headers** (plus `Mcp-Name` when the
|
|
231
238
|
body names one), and one that leaves a mirror out is refused just like one that contradicts the body: a gateway
|
|
232
239
|
rule keyed on a header never fires for the request that omitted it. Legacy requests are not checked. Capabilities
|
|
@@ -354,6 +361,53 @@ in-page chat lists none of them: its `/` menu is its six built-in commands. Pick
|
|
|
354
361
|
rule: a page taking no arguments is the most useful prompt, since MCP has no completion — one taking an id is for
|
|
355
362
|
when the id is already in hand.
|
|
356
363
|
|
|
364
|
+
## MCP Apps — The Page, Rendered In The Chat
|
|
365
|
+
**A `.prompt()` page is also a tool**, of the same name and arguments, marked `readOnlyHint`. Its result is the
|
|
366
|
+
prompt's briefing plus `Open this screen in a browser: <url>` — what a client without the
|
|
367
|
+
`io.modelcontextprotocol/ui` extension (Claude Code, Codex) reads. A host that declares the extension (claude.ai,
|
|
368
|
+
Claude Desktop, ChatGPT, VS Code, Goose) is also given `_meta.ui.resourceUri` (`ui://<app>/app`, or
|
|
369
|
+
`ui://<app>/app/<basePath>`) and renders the screen in place of the result: the app's CSR bundle, one HTML document,
|
|
370
|
+
in a sandboxed iframe started on the page the tool ran. Its first queries are answered from that run — carried in
|
|
371
|
+
the result's `_meta`, the one part no host shows the model — so the first paint costs no round trip; every later
|
|
372
|
+
call is relayed by the host as `tools/call` under the host's token. Nothing is declared for any of it.
|
|
373
|
+
|
|
374
|
+
- **What a view may call** is every published tool and every endpoint kept off the shelf by `mcp: false` alone,
|
|
375
|
+
listed with `visibility: ["app"]` — which a host offers to the view and never to the model. Visibility is a host's
|
|
376
|
+
UX, not authorization: the guards decide, as everywhere. A client that did not declare the extension is listed
|
|
377
|
+
neither those tools nor the `ui://` resources, a call to one answers `Unknown tool`, and a page tool's result
|
|
378
|
+
carries none of the view's data for it.
|
|
379
|
+
- **The capability is read per request.** A legacy client declares it once at `initialize` and is issued an
|
|
380
|
+
`Mcp-Session-Id` (`ui.<uuid>`) that stores nothing, since its id is the capability; with `modern: true` a modern
|
|
381
|
+
request carries it in `_meta`. A client that declares nothing is issued no session, exactly as before.
|
|
382
|
+
- **The view has no network.** `connect-src` stays closed; the app's own origin is opened for what a page loads by
|
|
383
|
+
URL — fonts (served with `Access-Control-Allow-Origin: *`) and stored images — and every root-relative URL the
|
|
384
|
+
page writes is pointed at it, since a host may block the document's `<base>` (Claude does).
|
|
385
|
+
Cookies and Web Storage are in-memory, history is in-memory, and the websocket, live sync, pubsub, the reconnect
|
|
386
|
+
overlay and `<Agent.Chat />` are off. A path link routes inside the view; any other link opens through the host.
|
|
387
|
+
- **The view looks like the host.** Its neutrals (`--background`, `--foreground`, `--muted`, `--border`, `--ring`) and
|
|
388
|
+
`--font-sans` / `--font-mono` follow the host's theme variables, its theme follows the host's, and so does its
|
|
389
|
+
language when the app speaks it; the app's brand colors and radii stay its own.
|
|
390
|
+
- **The host's model drives the screen too.** The view offers the host the screen's own tools — the surface
|
|
391
|
+
`<Agent.Chat />` reads: `st.tool`, the form setters, `fill<Model>Form`, `navigate` / `readScreen` / `readState` /
|
|
392
|
+
`highlight` — and, to a host that takes it, the screen's context (route, live state, `<Agent.Guide>`) through
|
|
393
|
+
`ui/update-model-context` whenever it moves. A tool that declares `confirm` asks the person in the view through
|
|
394
|
+
the `AgentApproval` slot, whatever the host asks on its own; a card tool, which a person answers in a chat, is not
|
|
395
|
+
offered.
|
|
396
|
+
- **A person tapping in the view acts with the agent's token**, so `isAgentCall` is true and a `Person` act is
|
|
397
|
+
refused there. That act belongs on the web page the result links to. The view holds no token: it is handed the
|
|
398
|
+
caller's claims, so `getSelf({ unauthorize })` in a layout renders the screen instead of a sign-in page, and each
|
|
399
|
+
call it makes is relayed and judged by the guards.
|
|
400
|
+
- **A view's call answers what a browser would get**, in `_meta` beside the masked result: the value with its
|
|
401
|
+
`visual` fields, or the domain `Err` with its key and data, toasted in the user's language. A refusal and a crash
|
|
402
|
+
stay generalized.
|
|
403
|
+
- **What a document load would do, the view does in place.** `router.refresh()` renders the route afresh, a
|
|
404
|
+
download (`<a download>`, a data table's export) goes to a host that saves files, and a full navigation off the
|
|
405
|
+
app shows a notice instead of a blank frame.
|
|
406
|
+
- **A page named like an endpoint stays a prompt only**, published or not, and the boot log says so: the endpoint
|
|
407
|
+
keeps its name. A refused screen answers as a tool error (`isError`), which the model reads.
|
|
408
|
+
- `option.setMcp({ apps: false })` / `AKAN_MCP_APPS=false` keeps every page a prompt only. `web: { csr: false }`
|
|
409
|
+
publishes the tool with no view, and an API-only build (`web: false`) has no pages to publish.
|
|
410
|
+
|
|
357
411
|
## Progress Reporting
|
|
358
412
|
**`McpProgress.report(n, { total, message })`** reports progress from anywhere inside a call, a service or adapter
|
|
359
413
|
frames down included, and is a no-op when nobody is streaming — so the same code runs unchanged over HTTP, a
|
|
@@ -421,8 +475,9 @@ the same guards.** What stays closed is closed because no agent has a reason to
|
|
|
421
475
|
|
|
422
476
|
## Writing `instructions`
|
|
423
477
|
|
|
424
|
-
`*.abstract.md` and `<Agent.Guide>`
|
|
425
|
-
published entry's `.desc()`, and a
|
|
478
|
+
`*.abstract.md` never reaches an MCP client, and `<Agent.Guide>` only through an MCP Apps view, beside the screen's own
|
|
479
|
+
tools it explains. Exactly three texts reach every client: `instructions`, every published entry's `.desc()`, and a
|
|
480
|
+
page's `.prompt()` description. All three are English, whatever the users speak
|
|
426
481
|
— the reader is a model, and the catalogue is built once in one `language` and cached by clients.
|
|
427
482
|
|
|
428
483
|
- **`instructions` is the workflow.** Five to eight sentences, in this order: what the app is and what its nouns
|
|
@@ -453,10 +508,10 @@ published entry's `.desc()`, and a page's `.prompt()` description. All three are
|
|
|
453
508
|
- The lib's own suites run wherever it is mounted: the OAuth signal test (metadata, registration, both `authorize`
|
|
454
509
|
redirects, code exchange, `/mcp` with the minted token and the refusal of a first-party or forged one, refresh
|
|
455
510
|
rotation) and the refresh-session and option unit tests.
|
|
456
|
-
- **One exposure test per app**, in
|
|
457
|
-
names with `toEqual`, and assert the curated and admin-only ones with
|
|
458
|
-
the guards, so a guard change fails this test first — which is the point.
|
|
459
|
-
`pkgs/akanjs/server/mcp/mcp.integration.test.ts`.
|
|
511
|
+
- **One exposure test per app**, in `srvkit/mcpExposure.signal.test.ts`, since it spans every module: list
|
|
512
|
+
`tools/list` with a user token and pin the names with `toEqual`, and assert the curated and admin-only ones with
|
|
513
|
+
`not.toContain`. Exposure is derived from the guards, so a guard change fails this test first — which is the point.
|
|
514
|
+
The framework's own pattern is `pkgs/akanjs/server/mcp/mcp.integration.test.ts`.
|
|
460
515
|
- Live: connect a client, consent, call a tool, `claude mcp logout`, authenticate again. `AKAN_PUBLIC_LOG_LEVEL=debug`
|
|
461
516
|
prints one `OAuth token request grant_type=… client_id=…` line per token call; two refreshes in the same second
|
|
462
517
|
both answering 200 is the grace window working, not a fault.
|
|
@@ -65,9 +65,15 @@ with it. `conventions` carries the invariants — this is the full contract behi
|
|
|
65
65
|
- `removeOne` / `updateOne` hit the **newest** match — the subquery they compile to is ordered `createdAt` descending
|
|
66
66
|
and there is no way to change that. They also report only counts, never which row they touched, so they are for
|
|
67
67
|
"there is at most one of these", not for claiming the next item off a queue. Pass a query that matches one row.
|
|
68
|
+
The query is a compare-and-set in every mode: of two concurrent `updateOne({ id, balance: { $gte: n } }, …)` that
|
|
69
|
+
both read the row as matching, only one applies, so `modifiedCount` is the verdict.
|
|
68
70
|
- **A filter may not be keyed after its own model.** Filter methods are assigned after CRUD, so a filter `chat` on
|
|
69
71
|
model `chat` would silently swap the single-document `removeChat`/`updateChat` for a hookless query-level one. It
|
|
70
72
|
throws at boot instead (`assertFilterFitsCrud`).
|
|
73
|
+
- **An insight field counts or sums.** `accumulate: {}` counts the rows the query matches and `accumulate: { status:
|
|
74
|
+
"active" }` those that also match the condition; `accumulate: { $sum: "totalValue", status: "active" }` adds the
|
|
75
|
+
field up over the same rows (`field(Float, { default: 0, accumulate: … })`), a stored value that is not a number
|
|
76
|
+
counting as nothing — the same answer on SQLite and Postgres.
|
|
71
77
|
- **"Has no value" is `q.empty`, never `q.missing`.** The three absence operators are distinct: `q.exists` is the
|
|
72
78
|
key being present, `q.missing` is the key being *absent from the stored JSON*, and `q.empty` is either — absent,
|
|
73
79
|
or present and null. An optional field is written both ways over its life: an insert that omits it leaves the key
|
|
@@ -119,7 +125,7 @@ with it. `conventions` carries the invariants — this is the full contract behi
|
|
|
119
125
|
- Declaring roles is all the wiring there is. Mirror rows are maintained by SQL triggers — not document hooks —
|
|
120
126
|
because `updateOneByQuery` and friends fire no hooks, and most searchable-field mutations go through exactly that
|
|
121
127
|
path.
|
|
122
|
-
- Search runs in every database mode: fts5 on SQLite
|
|
128
|
+
- Search runs in every database mode: fts5 on SQLite, a weighted `tsvector` under a GIN index on Postgres.
|
|
123
129
|
For the same text the same documents match — punctuation splits words the way unicode61 does, and a phrase never
|
|
124
130
|
runs from one column into the next — but the order may differ, since Postgres ranks without document frequency.
|
|
125
131
|
Assert on the match set, never on a Postgres order the weights alone do not decide.
|
|
@@ -185,6 +191,10 @@ shape, so `cascade` never means "related" — it means one of exactly these:
|
|
|
185
191
|
unmounted `refPath` candidate only warns, since that list spans optional modules by design.
|
|
186
192
|
- **Nothing checks whether another document still references the same target.** `File` in particular is deduped
|
|
187
193
|
by `origin`, so two parents can share one row; `removeRef` claims the field owns its target exclusively.
|
|
194
|
+
- **A reference no cascade removes keeps pointing at the removed row, and a response reads it live-only**: a list
|
|
195
|
+
leaves the target out, an optional field reads `null`, and a required one answers 404
|
|
196
|
+
`base.error.relationNotFound` naming the model and the field path. Make a reference whose target can go first
|
|
197
|
+
`.optional()`, or let it go with the target through `removeWith`.
|
|
188
198
|
- Removal is soft (`removedAt`), but the storage delete a `_postRemove` performs is not — a cascade is not
|
|
189
199
|
restorable, and reviving the owner does not revive what went with it.
|
|
190
200
|
- A `removeWith` declaration **auto-creates its index** (`{ removedAt, fk }`, or `{ removedAt, typeKey, fk }` when
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# Runtime Rule — Serving, Processes, Logging, Image, Native Apps, Desktop Server, Assets
|
|
1
|
+
# Runtime Rule — Serving, Processes, Logging, Image, Native Apps, Desktop Server, Assets, Build Pipeline
|
|
2
2
|
|
|
3
3
|
How an Akan app is built, what it serves, how many processes it runs, where its logs go, and what ends up in
|
|
4
4
|
its image. Everything here is declared in `akan.config.ts` or narrowed by an env at boot; none of it is reached
|
|
@@ -255,31 +255,62 @@ const config: AppConfig = {
|
|
|
255
255
|
other CPU of that OS: the Rust library, Bun's executable, the server's `bun install --cpu` and each `bin` file
|
|
256
256
|
follow it, and a server addon with no binary for that CPU stops the build. A macOS app is Apple silicon only —
|
|
257
257
|
Intel Macs are not a target, so `--arch x64` on macOS is refused.
|
|
258
|
-
- **A
|
|
258
|
+
- **A release is signed only with `--sign` and notarized only with `--notarize`** (`build-desktop`,
|
|
259
|
+
`publish-update`); the environment names the identity and never decides to use it, so a shell exporting a Developer
|
|
260
|
+
ID signs and uploads nothing unasked. Before either, the server the app carries is searched for every secret value
|
|
261
|
+
of the workspace's server env files and refused with the paths, and one line names the identity and the service
|
|
262
|
+
the app goes to.
|
|
263
|
+
- **A macOS release for download is signed with a Developer ID and notarized.**
|
|
259
264
|
`AKAN_NATIVE_MACOS_IDENTITY` names a keychain identity, or `AKAN_NATIVE_MACOS_CERTIFICATE` +
|
|
260
265
|
`_CERTIFICATE_PASSWORD` a `.p12` the build imports into a keychain of its own (a CI runner);
|
|
261
|
-
`AKAN_NATIVE_MACOS_NOTARY_KEY` + `_KEY_ID` + `_ISSUER` (an App Store Connect API key) or `_NOTARY_PROFILE`
|
|
262
|
-
|
|
266
|
+
`AKAN_NATIVE_MACOS_NOTARY_KEY` + `_KEY_ID` + `_ISSUER` (an App Store Connect API key) or `_NOTARY_PROFILE` are the
|
|
267
|
+
credentials `--notarize` staples the app and the dmg with. Every Mach-O file — the server's addons and `bin` included — is
|
|
263
268
|
signed inside out with the hardened runtime and a timestamp; the executable gets Bun's JIT entitlements and the
|
|
264
269
|
camera's or microphone's when a usage text asks for them, and `desktop.entitlements` adds the app's own.
|
|
265
|
-
`publish-update` signs with the same identity, since the updater checks the installed app's signature. Without a
|
|
270
|
+
`publish-update --sign` signs with the same identity, since the updater checks the installed app's signature. Without a
|
|
266
271
|
Developer ID the build warns, because Gatekeeper blocks a downloaded copy.
|
|
267
|
-
- **A Windows release is signed with Authenticode
|
|
272
|
+
- **A Windows release is signed with Authenticode.** `AKAN_NATIVE_WINDOWS_CERTIFICATE` +
|
|
268
273
|
`_CERTIFICATE_PASSWORD` (a `.pfx`), `AKAN_NATIVE_WINDOWS_THUMBPRINT` (a certificate in the store, a token's or an
|
|
269
274
|
HSM's), or `AKAN_NATIVE_WINDOWS_SIGN_COMMAND` (a JSON array run per file with `{file}`, e.g. signtool with Azure
|
|
270
275
|
Trusted Signing's dlib) signs every PE file of the app — the server's `.node` and `bin` included — the setup program
|
|
271
276
|
and the uninstaller it writes, SHA-256 with a timestamp (`AKAN_NATIVE_WINDOWS_TIMESTAMP_URL`).
|
|
272
|
-
- **`updates: { url,
|
|
277
|
+
- **`updates: { url, publicKeys }` lets an installed app update itself** — the whole app on a desktop, the web
|
|
273
278
|
bundle on a phone. `akan update-keygen <app>` makes the signing key once
|
|
274
|
-
(`~/.akan/native/keys/<app id>.update.key`, or the path `AKAN_NATIVE_UPDATE_KEY` names) and prints
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
+
(`~/.akan/native/keys/<app id>.update.key`, or the path `AKAN_NATIVE_UPDATE_KEY` names) and prints its public
|
|
280
|
+
key. List the online signing key first and offline spares after it (up to three): a phone binary trusts all of
|
|
281
|
+
them, and a key may revoke only the keys listed before it, so a leaked key cannot lock out its spares. A desktop
|
|
282
|
+
app trusts the first; `publicKey` is the one-key form. `akan publish-update <app>` builds and signs a release
|
|
283
|
+
into `updates/`: upload that folder to `url`, the manifests last, or let `--upload s3://<bucket>/<prefix>` do it
|
|
284
|
+
(credentials and endpoint from `S3_*` or `AWS_*`; a release file the bucket holds is skipped). A phone reads only
|
|
285
|
+
`<url>/<platform>/<channel>/<nativeApi>.json`, so each store generation on a channel takes its own fixes.
|
|
286
|
+
`akan pack-update` writes an unsigned phone bundle for whoever holds the key to sign and records it in
|
|
287
|
+
`<out>/pack.json`, exiting 2 when `--against` says it needs a new store build; `akan push-update` packs and uploads to
|
|
288
|
+
an akasys project that holds the key, and `akan register-binary` registers a store build after `release-*`. A
|
|
289
|
+
release that hangs is rolled back while the app runs, and `report: { url }` has a phone post what became of each
|
|
290
|
+
release (applied, confirmed, rolled back and why) at its next launch, so rollback rates are visible. `devtools: true`
|
|
291
|
+
opens the web inspector of a release build for an internal test target. The channel is the backend env the binary was built for unless `channel` names one;
|
|
292
|
+
on a desktop, `updates.setChannel({ channel })` moves the installed app to another manifest until set back with
|
|
293
|
+
`null` — a server putting a device on a pilot channel — and never to a release older than the one running. A
|
|
294
|
+
release that does not come up is rolled back to the one before.
|
|
279
295
|
- **An unattended screen** — signage, a kiosk — takes `desktop.recovery: "reload"` (a crashed page loads again,
|
|
280
|
-
and the app relaunches when the webview ends), `desktop.window: { fullscreen, skipTaskbar }`,
|
|
296
|
+
and the app relaunches when the webview ends), `desktop.window: { fullscreen, skipTaskbar, frameless }`,
|
|
281
297
|
`desktop.screenCapture: "auto"` (Windows answers `getDisplayMedia()` with the first screen, no picker) and
|
|
282
|
-
`android.autoplay` (media plays with sound without a tap).
|
|
298
|
+
`android.autoplay` (media plays with sound without a tap). Its carried server takes `desktop.server: { restart:
|
|
299
|
+
"forever", watchdog: true }` — restarted after every crash, never given up on, and killed and restarted when its
|
|
300
|
+
health route stops answering (every 10 s, 3 misses; `{ intervalMs, failures }` to tune) — and
|
|
301
|
+
`desktop.webview2: "standalone"` puts the whole WebView2 Runtime (~200 MB) in the Windows setup for a PC offline at
|
|
302
|
+
install. `desktop.server.env` adds variables to the server's start; the build refuses a secret-named one and those
|
|
303
|
+
the stage owns. The `window` plugin places it: `placeOnDisplay({ index, bounds, unit: "physical" })` puts a window
|
|
304
|
+
on one display in that display's device pixels — a 640×128 LED panel — lowering the minimum size to fit, beside
|
|
305
|
+
`setAlwaysOnTop`, `setCursorVisible` (which hides the pointer over its external views too, on Windows) and
|
|
306
|
+
`setDecorations`. `native.backgroundColor` (or `{ light, dark }`) is what a window and an external view show before
|
|
307
|
+
their page paints, so a dark board never flashes white, and `desktop.publisher` names who publishes a Windows app in
|
|
308
|
+
Apps & features and its executable's company (the app name when unset, never Bun's own).
|
|
309
|
+
- **A third-party page shown first-party** — a scoreboard, a menu board — is the `external-view` plugin, listed in
|
|
310
|
+
`native.plugins`: a webview over the window with a browser profile of its own, held to the origins it was opened
|
|
311
|
+
for, with an init script, `evaluate`, a trusted `sendKey`, `capture`, and `loadFailed` / `gone` (it reloads, or is
|
|
312
|
+
rebuilt, by itself) / `consoleMessage` events. Every method on Windows; macOS 14+ lacks `sendKey`, `setMuted` and
|
|
313
|
+
`capture`.
|
|
283
314
|
|
|
284
315
|
## A Desktop App's Server — `bin` And `trustedDependencies`
|
|
285
316
|
|
|
@@ -340,7 +371,8 @@ const config: AppConfig = {
|
|
|
340
371
|
- **An executable the desktop app spawns goes in `bin`** — its server's code or its native plugins, whether or not
|
|
341
372
|
it carries a server — keyed by the name the code spawns and then by
|
|
342
373
|
`${process.platform}-${process.arch}` (`darwin-arm64`, `darwin-x64`, `linux-arm64`, `linux-x64`, `win32-arm64`,
|
|
343
|
-
`win32-x64`)
|
|
374
|
+
`win32-x64`) — Node's names, unlike an update feed's folder, which is `<os>-<arch>` with the OS spelled `macos`,
|
|
375
|
+
`windows` or `linux` (`windows-x64/<channel>.json`). A source is `{ url, sha256, file? }` — downloaded when the app is built, refused unless it hashes
|
|
344
376
|
to `sha256` (so plain http is as safe as https), kept in `apps/<app>/.akan/cache/bin/<sha256>` — or
|
|
345
377
|
`{ path, file? }`, relative to the `akan.config.ts` that declares it. `file` is the executable inside an archive
|
|
346
378
|
(`.zip`, `.tar.gz`, `.tgz`, `.tar.xz`, `.tar.bz2`, `.tar`), unpacked with the OS's own `tar` (`unzip` for a zip
|
|
@@ -378,6 +410,18 @@ was still starting — the server sees its parent gone and ends the group itself
|
|
|
378
410
|
later for whatever ignored it. On Windows the job object ends them all with the app. A process started `detached`
|
|
379
411
|
leaves the group, or the job, and keeps running.
|
|
380
412
|
|
|
413
|
+
**An edge server carries no cloud secret.** The carried server runs `operationMode` edge, and the same app's cloud
|
|
414
|
+
deployment runs cloud: a module whose `serve(ref, { operationMode: ["edge"] }, …)` lists the modes it belongs to mounts
|
|
415
|
+
in those alone, and `modules: ["@edge"]` (or `AKAN_MODULES=@edge`) names every module that lists edge, with what they
|
|
416
|
+
inject, cascade to and hold a relation to. `desktop: { server: { profile: "edge", modules: ["@edge"] } }` builds the
|
|
417
|
+
carried server that way: `env/env.server.<env>.edge.ts` is bundled in place of `env/env.server.<env>.ts`, which never
|
|
418
|
+
enters it, and the build evaluates the profile file with no app env and refuses one whose secret-named keys hold a
|
|
419
|
+
value — a `process.env.X ?? "literal"` counts as the literal. Each lib's env defaults (`env.server.testing.ts`) are
|
|
420
|
+
swapped the same way: for the lib's `env/env.server.testing.edge.ts`, held to the same check, or for the defaults with
|
|
421
|
+
every secret-named value left out, which the build names in a warning. `akan build <app> --profile edge` does the
|
|
422
|
+
same for an image. A module listing only edge does not mount under `akan start` (local): list `local` too to develop
|
|
423
|
+
it there.
|
|
424
|
+
|
|
381
425
|
**A file the user picks reaches the server as a grant, never as a copy or a path.** With `"file-picker"` in
|
|
382
426
|
`native.plugins`, `filePicker.pickFiles({ forServer: true })` (also `pickDirectory` and `saveFile`,
|
|
383
427
|
from `akanjs/client/native`) copies nothing, whatever the size: its FileRefs serve the originals for a preview, and
|
|
@@ -417,7 +461,7 @@ lacks is added there: an app's own plugin runs as Bun code in the plugin host an
|
|
|
417
461
|
`single` and a cloud cluster in `cluster`: declare both.
|
|
418
462
|
- **Where the data lives is the deployment's env, ahead of `env.server.ts`**: `SQLITE_DATABASE_PATH`,
|
|
419
463
|
`AKAN_SOLID_DB_PATH`, `POSTGRES_URL` (or `POSTGRES_HOST`/`PORT`/`DATABASE`/`USER`/`PASSWORD`),
|
|
420
|
-
`POSTGRES_INSIGHT_URL
|
|
464
|
+
`POSTGRES_INSIGHT_URL`. `REDIS_URI` is required outside local development. Pool size, SSL and
|
|
421
465
|
prepared statements ride the Postgres URL (`?max=20&ssl=require`, `prepare=false` behind PgBouncer).
|
|
422
466
|
- **Uploads**: a deployed `multiple`/`cluster` app uses object storage, or a volume every instance mounts with
|
|
423
467
|
`AKAN_STORAGE_SHARED=true`.
|
|
@@ -508,3 +552,40 @@ const config: AppConfig = { assets: { pruneFonts: false } }; // emergency valve,
|
|
|
508
552
|
- The phase is `assets`, between `csr` and `compress`. An api-only build (`web: false`) copies no `public/` and
|
|
509
553
|
the phase finds nothing to do.
|
|
510
554
|
|
|
555
|
+
## The Build Pipeline — `pipeline` In `akan.config.ts`
|
|
556
|
+
|
|
557
|
+
Akan Cloud builds and deploys each app as `build` → `docker` → `deploy`, and `ota` after them when asked. **An app's own stages go in
|
|
558
|
+
`apps/<app>/akan.config.ts` under `pipeline`; stages around every app go in the workspace root's
|
|
559
|
+
`akan.config.ts`**, typed `WorkspaceConfig` (`before`, `after`, `post`, `env`, `timeout`). Both are `import type`
|
|
560
|
+
objects (or `(ctx) => …`), and `akan pipeline dry-run | validate | plan` turns them into the plan Akan Cloud runs —
|
|
561
|
+
locally, running nothing.
|
|
562
|
+
|
|
563
|
+
```ts
|
|
564
|
+
const config: AppConfig = {
|
|
565
|
+
pipeline: { stages: [{ use: "build" }, { name: "Test", sh: "bun run akan test web" }, { use: "docker" }, { use: "deploy" }] },
|
|
566
|
+
};
|
|
567
|
+
const config: WorkspaceConfig = {
|
|
568
|
+
pipeline: { post: { failure: [{ name: "Notify", sh: "./scripts/notify.sh", secrets: ["DISCORD_WEBHOOK"] }] } },
|
|
569
|
+
};
|
|
570
|
+
```
|
|
571
|
+
|
|
572
|
+
- **A declared list is the whole pipeline.** Without `pipeline` an app runs the three actions; with it, write them
|
|
573
|
+
— a list without `{ use: "deploy" }` never deploys. Each action appears at most once, in that order, and
|
|
574
|
+
`docker` needs a `build` before it (the image is made from that build).
|
|
575
|
+
- **`{ use: "ota" }` ships the native web bundle over the air**, after `deploy` so the API it calls is already up.
|
|
576
|
+
It is opt-in, makes its own CSR build, and packs every native target that declares `native.updates` for iOS
|
|
577
|
+
and Android — `targets` and `platforms` narrow it, and a target without `updates` is a plan error. Akan Cloud
|
|
578
|
+
signs and publishes the packs; the plan never holds a key.
|
|
579
|
+
- **A stage runs after the action before it.** Stages between two actions share a phase across apps, and a phase
|
|
580
|
+
waits for every stage of the one before; `deploy` runs the apps one at a time in `after` order.
|
|
581
|
+
- **App stages stand on their own.** Never rely on what a root `before` stage left on disk: the root config never
|
|
582
|
+
travels to a subspace, so an app built there runs its own stages only. Keep the files an app stage needs inside
|
|
583
|
+
the app or its libs.
|
|
584
|
+
- **Secrets are named and write-only.** `akan upload-secret <NAME>` stores one per project (`--branch` for a
|
|
585
|
+
branch-only value); a step lists it in `secrets` and reads `$NAME`. Anyone who can push to the repo can make a
|
|
586
|
+
step print it — the GitHub Actions model — so push access is secret access.
|
|
587
|
+
- **`allowFailure` only means "this failure does not fail the run"**; how the run is reported is Akan Cloud's
|
|
588
|
+
call. `env` and secret names may not be `CI`, `AKAN_CI_*` or shell names such as `PATH`, and every step gets
|
|
589
|
+
`AKAN_CI_BRANCH`, `AKAN_CI_APP` and the rest of the build's facts.
|
|
590
|
+
- `akan pipeline` exits 0 with a plan, 1 with an envelope of errors, and 2 for anything else (no envelope). A lib
|
|
591
|
+
config takes no `pipeline`; the root config takes no app or lib settings.
|
|
@@ -125,21 +125,24 @@ Three rules the shape enforces:
|
|
|
125
125
|
**A failure now surfaces where it happened.** One `Promise.all` in the page body reports nothing about which of
|
|
126
126
|
eight queries threw; per-section boundaries put each rejection at its own boundary.
|
|
127
127
|
|
|
128
|
-
**
|
|
129
|
-
|
|
130
|
-
|
|
128
|
+
**Know which redirects are real.** A streaming response starts while layout and page bodies still run, so a redirect
|
|
129
|
+
is a real 3xx only when it was raised before any I/O was awaited, the body's own or a layout's above it (a layout that
|
|
130
|
+
awaits `fetch.viewOrg` makes every page under it late); after one — `getSelf({ unauthorize: "/signin" })` fetches —
|
|
131
|
+
it reaches the browser as an HTTP 200 that navigates in the page, which a crawler or `curl` does not
|
|
132
|
+
follow. Either way no markup below the redirect is sent. A route that must answer a real 3xx after I/O — a gate an
|
|
133
|
+
external client hits, a moved URL — takes `.config({ ssr: "block" })`: the response waits for the whole render.
|
|
134
|
+
Never redirect from inside a streamed section.
|
|
131
135
|
|
|
132
136
|
**⑥ Use named `ReactNode` slots, not just `children`.** A client shell that takes one slot per region lets a
|
|
133
137
|
server component supply each of them, instead of absorbing the whole subtree into the bundle. `Layout.Navbar`
|
|
134
|
-
|
|
135
|
-
`children` — copy the shape, not that component.
|
|
138
|
+
is the shape: `title` / `left` / `right` beside `back` and `children`, each filled by the server.
|
|
136
139
|
|
|
137
140
|
**⑦ Let the server do the derived work.** Display and predicate logic belongs on `Light<Model>` (`isNew()`,
|
|
138
141
|
`canWrite(user?)`, `formatTimes()`), and enum→class lookups belong in a module-scope `as const` map. Both sides call
|
|
139
142
|
the same method, so a client component that exists only to compute a label is markup in the wrong place.
|
|
140
143
|
|
|
141
|
-
**⑧ Gate auth on the server.** `getSelf({ unauthorize: "/signin" })` in `_layout.tsx` redirects before any
|
|
142
|
-
|
|
144
|
+
**⑧ Gate auth on the server.** `getSelf({ unauthorize: "/signin" })` in `_layout.tsx` redirects before any of the
|
|
145
|
+
page's markup is rendered. A client-side check costs a hydration round-trip and flashes the wrong UI first.
|
|
143
146
|
|
|
144
147
|
**⑨ Prefer CSS over client state for pure visibility.** A `data-*` attribute plus `group-data-[…]` variants (see
|
|
145
148
|
`libs/util/ui/Grid/*`) or `<details>`/`<summary>` keeps both branches server-rendered. Reach for `useState` when the
|
|
@@ -21,8 +21,27 @@ mutation is mounted under. `conventions` carries the invariants — this is the
|
|
|
21
21
|
`null` rather than a placeholder when genuinely unknown — a loopback-looking address for an unknown caller
|
|
22
22
|
is the failure this replaced. The gateway also forwards the client port (`x-forwarded-port`, read with
|
|
23
23
|
`context.getClientPort()`) along with host and protocol.
|
|
24
|
+
- **A device or another server proves itself with a machine token.** `MachineToken.issue({ kind, id, version },
|
|
25
|
+
{ expiresAt? })` in `akanjs/server` signs an EdDSA JWT (`typ: akan-machine+jwt`, `aud: <app>/<environment>`) with
|
|
26
|
+
`AKAN_MACHINE_PRIVATE_KEY`; it never expires unless asked. `akan machine-keygen <file>` writes that key to a file
|
|
27
|
+
and prints only the public one. A server with either key verifies the bearer of every call — HTTP, and the
|
|
28
|
+
websocket's auth frame — after every lib middleware, and sets `account.machine = { kind, id, version }` from a
|
|
29
|
+
verified token; it takes off a `machine` any other middleware or a session's claims put there, so a staff token
|
|
30
|
+
can never pass for one, and a session middleware already reads the EdDSA token as anonymous. `.with(Machine)` hands
|
|
31
|
+
the identity (401 without one; `{ nullable: true }` takes `null`), and a pubsub room's guards see it on subscribe
|
|
32
|
+
and on every credential change. Revocation is the app's: a guard compares `machine.version` with the record's
|
|
33
|
+
`tokenVersion`, which a removal or a reinstall bumps. An endpoint taking `.with(Machine)` on a server with no key
|
|
34
|
+
is an error at boot. The calling side is `fetch.clone({ origin, jwt: token, connect: true })`.
|
|
24
35
|
- **Every socket carries a `socketId`, and only the framework mints one.** `AppWsData` assigns it at the handshake, and a `message` / `pubsub` handler reads it off the `Ws` internal arg — `.with(Ws)` hands `{ ws, socketId, subscribe, on, off }`. Never mint your own from `ws.data`: the room bookkeeping keys on the framework's id, and a second one fails to match it silently. It identifies a **connection**, not a caller — a reconnect gets a new id and the federation gateway's own socket is a different one — so per-user state keys on the account, never on this.
|
|
25
36
|
|
|
37
|
+
- **A socket that dropped comes back on a backoff, and asks for what it missed.** The client waits 1 s doubling to
|
|
38
|
+
30 s, cut at random so a deploy's dropped clients return apart; `fetch.clone({ ws: { reconnect: { min, max, jitter,
|
|
39
|
+
maxAttempts } | false, headers, tls, onStateChange, onRevoked, onError } })` (or `fetch.instance.configureWs(…)`)
|
|
40
|
+
tunes it, and a server-side client sends `headers` and its token with the upgrade. A server numbers the JSON frames
|
|
41
|
+
each room delivers (`seq`); a client drops a repeat, and a hole — a frame dropped under backpressure — calls the
|
|
42
|
+
room's `onResync`. A resubscribe names its last frame (`since`): a server that saw every publish (a solo process)
|
|
43
|
+
replays what followed it ahead of the ack, and otherwise answers a gap, which calls `onResync` as before. Binary
|
|
44
|
+
rooms are not numbered.
|
|
26
45
|
- **A cleanup registered with `ws.on("disconnect" | "unsubscribe", fn)` is scoped to the call that registered it.**
|
|
27
46
|
From a `pubsub` subscribe it belongs to that room — `unsubscribe` runs when the client leaves it or a credential
|
|
28
47
|
change revokes it, `disconnect` when the socket closes while still subscribed — so a room already left runs
|
|
@@ -187,8 +187,8 @@ export class TaskFilter extends from(cnst.Task, (filter) => ({
|
|
|
187
187
|
}))
|
|
188
188
|
|
|
189
189
|
// 4. In page — init the slice inside the page chain's render and hand the init to a Zone.
|
|
190
|
-
export default page().render(
|
|
191
|
-
const
|
|
190
|
+
export default page().render(() => {
|
|
191
|
+
const { taskInitInTodo } = fetch.initTaskInTodo();
|
|
192
192
|
return <Task.Zone.Card init={taskInitInTodo} sliceName="taskInTodo" />;
|
|
193
193
|
});
|
|
194
194
|
```
|
|
@@ -349,8 +349,8 @@ export function generateHead({ params }: PageProps) {
|
|
|
349
349
|
}
|
|
350
350
|
export default async function Page({ params: { projectId }, searchParams }: PageProps) {
|
|
351
351
|
const { l } = usePage();
|
|
352
|
-
const {
|
|
353
|
-
return <Project.Zone.View view={
|
|
352
|
+
const { projectView } = await fetch.viewProject(projectId);
|
|
353
|
+
return <Project.Zone.View view={projectView} tab={searchParams.tab} />;
|
|
354
354
|
}
|
|
355
355
|
export const pageConfig = { transition: "stack" } satisfies PageConfig;
|
|
356
356
|
|
|
@@ -367,8 +367,8 @@ export default page()
|
|
|
367
367
|
.prompt("briefProject", "Brief one project: its status and what is due.")
|
|
368
368
|
.render(async ({ projectId, tab }) => {
|
|
369
369
|
const { l } = usePage();
|
|
370
|
-
const {
|
|
371
|
-
return <Project.Zone.View view={
|
|
370
|
+
const { projectView } = await fetch.viewProject(projectId);
|
|
371
|
+
return <Project.Zone.View view={projectView} tab={tab} />;
|
|
372
372
|
});
|
|
373
373
|
```
|
|
374
374
|
|
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
CodegenLock,
|
|
9
9
|
WorkspaceExecutor,
|
|
10
10
|
AppExecutor
|
|
11
|
-
} from "./index-
|
|
11
|
+
} from "./index-55mynnqs.js";
|
|
12
12
|
import"./index-52w0fvh8.js";
|
|
13
13
|
import {
|
|
14
14
|
GraphClientEntryDiscovery,
|
|
@@ -20,7 +20,7 @@ import {
|
|
|
20
20
|
DevChangePlanner,
|
|
21
21
|
DevGeneratedIndexSync,
|
|
22
22
|
SsrDevBundler
|
|
23
|
-
} from "./index-
|
|
23
|
+
} from "./index-aprt7pkz.js";
|
|
24
24
|
import {
|
|
25
25
|
prepareDevWatchBatch
|
|
26
26
|
} from "./index-a5vmm6x4.js";
|
|
@@ -235,6 +235,7 @@ class IncrementalBuilder {
|
|
|
235
235
|
#autoImportSync;
|
|
236
236
|
#watcher = null;
|
|
237
237
|
#generation = 0;
|
|
238
|
+
#shellGeneration = null;
|
|
238
239
|
#discoveredThrough = 0;
|
|
239
240
|
#csrActive = IncrementalBuilder.#csrArmedByEnv();
|
|
240
241
|
#csrBundler;
|
|
@@ -852,7 +853,7 @@ class IncrementalBuilder {
|
|
|
852
853
|
if (crashedNeeds.includes("ssr"))
|
|
853
854
|
this.#ssrPatcher?.releaseHandedOver();
|
|
854
855
|
for (const need of crashedNeeds)
|
|
855
|
-
if (need !== "base")
|
|
856
|
+
if (need !== "base" && need !== "shell")
|
|
856
857
|
this.#sendBuildStatus(need, {
|
|
857
858
|
generation,
|
|
858
859
|
ok: false,
|
|
@@ -916,6 +917,10 @@ class IncrementalBuilder {
|
|
|
916
917
|
this.#logger.verbose(`announced boot state after recycle generation=${generation}`);
|
|
917
918
|
}
|
|
918
919
|
async handleBuildCsr(msg) {
|
|
920
|
+
if (msg.shell) {
|
|
921
|
+
await this.#buildShell(msg);
|
|
922
|
+
return;
|
|
923
|
+
}
|
|
919
924
|
this.#beginCsrArm();
|
|
920
925
|
this.#csrActive = true;
|
|
921
926
|
const armed = this.#workQueue.enqueue("build-csr", async () => {
|
|
@@ -940,6 +945,22 @@ class IncrementalBuilder {
|
|
|
940
945
|
this.#csrGate = this.#armingCsr(armed);
|
|
941
946
|
await armed;
|
|
942
947
|
}
|
|
948
|
+
async#buildShell(msg) {
|
|
949
|
+
await this.#workQueue.enqueue("build-shell", async () => {
|
|
950
|
+
const generation = this.#generation;
|
|
951
|
+
let error;
|
|
952
|
+
if (this.#shellGeneration !== generation)
|
|
953
|
+
try {
|
|
954
|
+
const result = await this.#batchRunner.run(await this.#batchRequest({ generation, needs: ["shell"], changedFiles: [] }));
|
|
955
|
+
error = result.errors.shell;
|
|
956
|
+
if (!error)
|
|
957
|
+
this.#shellGeneration = generation;
|
|
958
|
+
} catch (err) {
|
|
959
|
+
error = ApplicationBuildReporter.formatError(err, this.#app.workspace.workspaceRoot);
|
|
960
|
+
}
|
|
961
|
+
await BuilderChannel.send(error ? { type: "build-csr-res", id: msg.id, ok: false, error } : { type: "build-csr-res", id: msg.id, ok: true });
|
|
962
|
+
});
|
|
963
|
+
}
|
|
943
964
|
#beginCsrArm() {
|
|
944
965
|
if (this.#csrArms === 0) {
|
|
945
966
|
this.#csrWasActive = this.#csrActive;
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
// @bun
|
|
2
2
|
import {
|
|
3
3
|
AppExecutor
|
|
4
|
-
} from "./index-
|
|
4
|
+
} from "./index-55mynnqs.js";
|
|
5
5
|
import {
|
|
6
6
|
jsonText,
|
|
7
7
|
createWorkflowBaselineSummary,
|
|
8
8
|
buildAkanModuleContextIndex,
|
|
9
9
|
toolingRolloutGate
|
|
10
|
-
} from "./index-
|
|
10
|
+
} from "./index-05g3jayg.js";
|
|
11
11
|
import {
|
|
12
12
|
buildResourceList,
|
|
13
13
|
codexMcpConfigPath,
|
|
@@ -17,25 +17,27 @@ import {
|
|
|
17
17
|
upsertCodexMcpServerBlock,
|
|
18
18
|
renderDoctorText,
|
|
19
19
|
AkanContextAnalyzer
|
|
20
|
-
} from "./index-
|
|
20
|
+
} from "./index-2mx692qq.js";
|
|
21
21
|
import {
|
|
22
22
|
runner
|
|
23
|
-
} from "./index-
|
|
23
|
+
} from "./index-6jjtnhkw.js";
|
|
24
24
|
import {
|
|
25
|
-
getMobileTargets,
|
|
26
|
-
appIdsOf,
|
|
27
25
|
NativeApp,
|
|
28
26
|
isPlaceholderAppId
|
|
29
|
-
} from "./index-
|
|
27
|
+
} from "./index-kjvrq3nc.js";
|
|
30
28
|
import {
|
|
31
29
|
Prompter2
|
|
32
30
|
} from "./index-zzvvc845.js";
|
|
33
31
|
import {
|
|
34
32
|
RepairRunner
|
|
35
|
-
} from "./index-
|
|
33
|
+
} from "./index-fm46xrf1.js";
|
|
36
34
|
import {
|
|
37
35
|
WorkflowRunner
|
|
38
|
-
} from "./index-
|
|
36
|
+
} from "./index-rwq3ekqc.js";
|
|
37
|
+
import {
|
|
38
|
+
getMobileTargets,
|
|
39
|
+
appIdsOf
|
|
40
|
+
} from "./index-q958tqqg.js";
|
|
39
41
|
|
|
40
42
|
// pkgs/@akanjs/devkit/akanMcpContract.ts
|
|
41
43
|
import path from "path";
|
|
@@ -947,7 +949,7 @@ ${payload}`);
|
|
|
947
949
|
"add-enum-field": "`akan add-enum-field --app <app-or-lib> --module <module> --field <field> --values a,b --format json` adds an enum field to source constant/dictionary files without editing generated files.",
|
|
948
950
|
sync: "`akan sync <app-or-lib>` refreshes generated Akan files from source conventions.",
|
|
949
951
|
lint: "`akan lint <app-or-lib-or-pkg>` runs Biome linting after preparing generated files.",
|
|
950
|
-
typecheck: "`akan typecheck <app-
|
|
952
|
+
typecheck: "`akan typecheck <app-or-lib>` typechecks an app or a library after preparing generated files, its tests and specs included (`--tests false` to leave them out; a build does).",
|
|
951
953
|
test: "`akan test <app-or-lib-or-pkg>` prepares the target and runs its tests.",
|
|
952
954
|
build: "`akan build <app-name>` creates a production build after preparing generated files.",
|
|
953
955
|
doctor: "`akan doctor --strict --format json` reports agent-readable workspace convention diagnostics and validation hints.",
|