@ultimat3/cli 30.3.0 → 30.4.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/CLAUDE.md +6 -6
- package/package.json +28 -28
- package/src/app-mcp.ts +82 -47
- package/src/cmd-upgrade-spec.ts +1 -1
- package/src/island-bundle.ts +3 -1
- package/src/island-realtime.ts +66 -3
- package/src/role-start.ts +23 -7
- package/src/runtime-jobs-unserved.ts +46 -0
- package/src/runtime-jobs.ts +0 -17
- package/src/templates/entity-module.ts +7 -2
- package/src/templates/scaffold-db-client.ts +22 -2
- package/src/templates/scaffold-mcp-admin.ts +99 -0
- package/src/templates/scaffold-mcp-package.ts +5 -2
- package/src/upgrade-pins.ts +6 -5
- package/src/upgrade-plan.ts +44 -17
- package/src/upgrade-report.ts +4 -1
- package/src/verify-checks.ts +3 -0
- package/src/verify-mcp-mount.ts +30 -0
- package/wiki/Admin-Dashboard.md +31 -0
- package/wiki/CLI-Reference.md +1 -1
- package/wiki/Configuration.md +1 -1
- package/wiki/Deployment.md +1 -1
- package/wiki/Jobs-And-Workflows.md +2 -0
- package/wiki/MCP-And-AI.md +5 -2
- package/wiki/Testing.md +15 -4
- package/wiki/Upgrading.md +3 -2
package/CLAUDE.md
CHANGED
|
@@ -5,7 +5,7 @@ dashboard), `cli → testing` (islands, the e2e step, `x shot`'s `launchChrome`)
|
|
|
5
5
|
this except `create-ultimate`. The reasoning behind every rule below, verbatim and dated, is
|
|
6
6
|
[`docs/history/cli.md`](../../docs/history/cli.md); where the two disagree this file wins.
|
|
7
7
|
|
|
8
|
-
Commands: `bun test packages/cli` (from the repo root
|
|
8
|
+
Commands: `bun test packages/cli` (from the repo root: the preload),
|
|
9
9
|
`bunx tsc --noEmit -p packages/cli/tsconfig.json`.
|
|
10
10
|
|
|
11
11
|
## Rules
|
|
@@ -51,13 +51,13 @@ Commands: `bun test packages/cli` (from the repo root — the test preload lives
|
|
|
51
51
|
| `boundary-findings.ts` / `app-boundaries.ts` / `boundary-cuts.ts` / `package-boundaries.ts` | `package-boundaries.ts`: `X_BOUNDARY_PACKAGE_TO_APP`, its own read of `packages/`, never `readAppSources`. surface + layer rules; a finding's `fix:` is the concrete cut |
|
|
52
52
|
| `app-transport.ts` / `browser-transport.ts` / `transport-calls.ts` / `import-closure.ts` / `server-barrels.ts` | `boundaries`, built in: one browser transport. Never a `guards/` file: an app must not be able to delete it. Full rule: `docs/history/cli.md` |
|
|
53
53
|
| `guards.ts` / `guard-sources.ts` | an app's `guards/*.ts`, discovered, run on `boundaries`, held to the error contract; every `check(root, sources)` shares ONE walk, read and Sass compile per file (`GuardSources`) |
|
|
54
|
-
| `templates/scaffold-guards.ts` | `SHIPPED` — the one list of the twenty-one shipped guards
|
|
54
|
+
| `templates/scaffold-guards.ts` | `SHIPPED` — the one list of the twenty-one shipped guards: `x new` writes all, `x g guard <name>` writes one, `doctor-guards.ts` lists the ones an app lacks (a listing, never a finding). Full rule: `docs/history/cli.md` |
|
|
55
55
|
| `error-contract.ts` / `ts-scan.ts` / `fix-scan.ts` / `fix-imports.ts` / `fix-command.ts` / `fix-path.ts` | the `errors` step: every `fix:` names a runnable command, call or existing file |
|
|
56
56
|
| `workspace-checks.ts` / `test-only-shipped.ts` / `workspace-graph.ts` / `tsconfig-references.ts` / `publish-closure.ts` | `package-shape`, `filesize`. Full rule: `docs/history/cli.md` |
|
|
57
57
|
| `app-permissions.ts` / `permission-grants.ts` / `app-permissions-borrowed.ts` / `app-permissions-site.ts` | `policy`: every permission granted or required is declared and granted (`X_PERMISSION_UNGRANTED`, `X_PERMISSION_BORROWED`). Full rule: `docs/history/cli.md` |
|
|
58
58
|
| `job-registration.ts` | `manifest`: no job or task under a positional `anonymous-*` name (`X_JOB_UNREGISTERED`) |
|
|
59
59
|
| `unscanned-admin.ts` | `manifest`: a `defineAdmin()` outside the app scan (`apps/*/src/`) is `X_ADMIN_UNSCANNED` |
|
|
60
|
-
| `tool-gate-names.ts` | `manifest`: a gate on no tool |
|
|
60
|
+
| `tool-gate-names.ts` · `verify-mcp-mount.ts` | `manifest`: a gate on no tool · `expose` and no `POST /mcp` |
|
|
61
61
|
| `verify-role-load.ts` / `verify-role-load-probe.ts` | `manifest`: a child imports the app as a worker does, then the rest module by module; a registration only a document-reaching module makes is `X_ROLE_LOAD_INCOMPLETE` with the module and the import the API index lacks |
|
|
62
62
|
| `async-pages.ts` / `live-routes.ts` / `budgets.ts` / `budgets-unmeasured.ts` | `budgets` riders: an async `Page` with no `load`, a live read no island imports, bytes per route; an unweighed route's finding names a refused measurement actor and `defineMeasurementActor()` |
|
|
63
63
|
| `budgets-freshness.ts` / `verify-budgets-build.ts` | the step builds first when stats are missing, of an old rule, or older than source: `x build --target static --no-preflight --out .x/verify-static` through `ctx.runner` |
|
|
@@ -95,7 +95,7 @@ Commands: `bun test packages/cli` (from the repo root — the test preload lives
|
|
|
95
95
|
| `generate-feature-required.ts` | `x g query`/`backfill` with no slice holding an `entity.ts` is `X_FEATURE_REQUIRED` |
|
|
96
96
|
| `generate-sign-in*.ts` | `x g sign-in`: one flag, no name; OIDC swaps only `x new`'s bytes |
|
|
97
97
|
| `generate-format-written.ts` | the app's Biome over the TypeScript a run created, best effort; `lint` judges |
|
|
98
|
-
| `templates/scaffold-mcp-json
|
|
98
|
+
| `templates/scaffold-mcp-{json,endpoint,admin}.ts` | `.mcp.json` (`bunx @ultimat3/cli mcp serve`, never `bunx x`), the refusing `apps/web/mcp.ts` + `app/auth/agent-token.ts`, `apps/admin/mcp.ts`; `scaffold-agents-md.test.ts` resolves every command `AGENTS.md` names |
|
|
99
99
|
|
|
100
100
|
### Boot (`x dev`, the container)
|
|
101
101
|
|
|
@@ -113,7 +113,7 @@ Commands: `bun test packages/cli` (from the repo root — the test preload lives
|
|
|
113
113
|
| `app-config-load.ts` | the ONE reader of an app's `app.config.ts`: `loadAppConfig(root)` — the `config` export through core's `defineConfig`, so validated and default-merged; `undefined` with no file; no `config` object is `X_CONFIG_INVALID`. Full rule: `docs/history/cli.md` |
|
|
114
114
|
| `theme-brand.ts` | the ONE reader of an app's theme: `export const brand` in `apps/web/shared/theme.ts` (ui's `defineTheme`), inlined after the surface stylesheet with its hash in `style-src`; none → nothing emitted |
|
|
115
115
|
| `runtime-jobs.ts` / `runtime-realtime.ts` / `runtime-notify-retention.ts` / `runtime-cache.ts` / `runtime-purge.ts` / `runtime-replica.ts` | `app.config.ts` sections the boot obeys (`*Of(config)` over the one loaded config), and what each wires |
|
|
116
|
-
| `runtime-jobs.ts` (queues) | `jobs.queues` ∪ registered
|
|
116
|
+
| `runtime-jobs.ts` (queues) | `jobs.queues` ∪ registered; `WORKER_QUEUES` = the EXACT set, each in that union or `X_WORKER_QUEUE_UNKNOWN`; `-unserved.ts`: what no live worker serves |
|
|
117
117
|
| `serve-retire.ts` / `dev-restart-retire.ts` | the worker retire, `stop()` claiming and aborting nothing: SIGUSR2 under `runRole`, and before a supervised `x dev` restart's drain (#677). Full rule: `docs/history/cli.md` |
|
|
118
118
|
| `role-start.ts` / `role-start-types.ts` / `role-realtime.ts` / `role-start-devices.ts` | `--role` selection and start/stop for `web`, `sync`, `worker`, `scheduler`; `realtime.enabled: false` drops `sync` and `replicator`; device mounts, swapped per path on save |
|
|
119
119
|
| `role-wake.ts` | the worker's LISTEN session (`@ultimat3/jobs`' `startQueueWake`) — worker role only, only over a client that `canListen` — and `queue_wake_live` |
|
|
@@ -131,7 +131,7 @@ Commands: `bun test packages/cli` (from the repo root — the test preload lives
|
|
|
131
131
|
| `island-bundle.ts` / `island-store.ts` / `island-sources.ts` / `island-realtime.ts` / `island-realtime-state.ts` / `island-runtime.ts` / `solid-loader.ts` | islands: one `Bun.build` each, source-addressed; the image build writes a verified store, and an edit since the build is a rebuild, never the old chunk |
|
|
132
132
|
| `local-cli.ts` / `local-cli-handoff.ts` | a global `x` hands over to the app's own CLI (`local-cli.ts` decides whether; `x upgrade` alone stays when the app's is strictly older — `upgrade-handover.test.ts`) |
|
|
133
133
|
| `serve-prebuilt.ts` / `serve-prebuilt-paths.ts` | the image's prebuilt store (`node_modules/.cache/ultimate`, never `.x/` — state, tmpfs): every role adopts its compiled stylesheets before importing the app; a boot that built an island or ran Sass logs `X_IMAGE_NOT_PREBUILT` once |
|
|
134
|
-
| `dev-*.ts` | `x dev` only: dashboard sources, traces, the N+1 ledger,
|
|
134
|
+
| `dev-*.ts` | `x dev` only: dashboard sources, traces, the N+1 ledger, watcher, reload, lock, port |
|
|
135
135
|
| `cmd-stop.ts` / `cmd-stop-spec.ts` | `x stop`: the lock's pid only (its recorded start time must match), SIGTERM, then wait for pid gone and lock clear; Windows clears the lock (`drained: false`); `X_DEV_STOP_TIMEOUT` |
|
|
136
136
|
| `dev-dashboard-guard.ts` / `loopback-host.ts` | `/_x` answers a loopback `Host` only (`X_DEV_HOST_REFUSED`, 421 — DNS rebinding); the SQL panel runs on a same-origin `POST /_x/db` only, through admin's `readOnlySql` |
|
|
137
137
|
| `doctor-pooler.ts` / `runtime-statement-bound.ts` | the applied bound on demand (`data.statementBound`), a pooler's refusal as the one database finding; the one boot line, total. `startDb` hands `poolerFromEnv(env)` to both pools |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/cli",
|
|
3
|
-
"version": "30.
|
|
3
|
+
"version": "30.4.0",
|
|
4
4
|
"description": "The `x` binary: new, dev, build, verify, generate, db, mcp, doctor, deploy",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ultimate",
|
|
@@ -53,33 +53,33 @@
|
|
|
53
53
|
},
|
|
54
54
|
"dependencies": {
|
|
55
55
|
"@babel/core": "^8.0.7",
|
|
56
|
-
"@ultimat3/action": "30.
|
|
57
|
-
"@ultimat3/admin": "30.
|
|
58
|
-
"@ultimat3/ai": "30.
|
|
59
|
-
"@ultimat3/auth": "30.
|
|
60
|
-
"@ultimat3/cache": "30.
|
|
61
|
-
"@ultimat3/core": "30.
|
|
62
|
-
"@ultimat3/db": "30.
|
|
63
|
-
"@ultimat3/entity": "30.
|
|
64
|
-
"@ultimat3/flags": "30.
|
|
65
|
-
"@ultimat3/http": "30.
|
|
66
|
-
"@ultimat3/i18n": "30.
|
|
67
|
-
"@ultimat3/jobs": "30.
|
|
68
|
-
"@ultimat3/mail": "30.
|
|
69
|
-
"@ultimat3/manifest": "30.
|
|
70
|
-
"@ultimat3/mcp": "30.
|
|
71
|
-
"@ultimat3/money": "30.
|
|
72
|
-
"@ultimat3/notify": "30.
|
|
73
|
-
"@ultimat3/policy": "30.
|
|
74
|
-
"@ultimat3/pwa": "30.
|
|
75
|
-
"@ultimat3/query": "30.
|
|
76
|
-
"@ultimat3/realtime": "30.
|
|
77
|
-
"@ultimat3/render": "30.
|
|
78
|
-
"@ultimat3/schema": "30.
|
|
79
|
-
"@ultimat3/seo": "30.
|
|
80
|
-
"@ultimat3/storage": "30.
|
|
81
|
-
"@ultimat3/testing": "30.
|
|
82
|
-
"@ultimat3/time": "30.
|
|
56
|
+
"@ultimat3/action": "30.4.0",
|
|
57
|
+
"@ultimat3/admin": "30.4.0",
|
|
58
|
+
"@ultimat3/ai": "30.4.0",
|
|
59
|
+
"@ultimat3/auth": "30.4.0",
|
|
60
|
+
"@ultimat3/cache": "30.4.0",
|
|
61
|
+
"@ultimat3/core": "30.4.0",
|
|
62
|
+
"@ultimat3/db": "30.4.0",
|
|
63
|
+
"@ultimat3/entity": "30.4.0",
|
|
64
|
+
"@ultimat3/flags": "30.4.0",
|
|
65
|
+
"@ultimat3/http": "30.4.0",
|
|
66
|
+
"@ultimat3/i18n": "30.4.0",
|
|
67
|
+
"@ultimat3/jobs": "30.4.0",
|
|
68
|
+
"@ultimat3/mail": "30.4.0",
|
|
69
|
+
"@ultimat3/manifest": "30.4.0",
|
|
70
|
+
"@ultimat3/mcp": "30.4.0",
|
|
71
|
+
"@ultimat3/money": "30.4.0",
|
|
72
|
+
"@ultimat3/notify": "30.4.0",
|
|
73
|
+
"@ultimat3/policy": "30.4.0",
|
|
74
|
+
"@ultimat3/pwa": "30.4.0",
|
|
75
|
+
"@ultimat3/query": "30.4.0",
|
|
76
|
+
"@ultimat3/realtime": "30.4.0",
|
|
77
|
+
"@ultimat3/render": "30.4.0",
|
|
78
|
+
"@ultimat3/schema": "30.4.0",
|
|
79
|
+
"@ultimat3/seo": "30.4.0",
|
|
80
|
+
"@ultimat3/storage": "30.4.0",
|
|
81
|
+
"@ultimat3/testing": "30.4.0",
|
|
82
|
+
"@ultimat3/time": "30.4.0",
|
|
83
83
|
"babel-preset-solid": "^1.9.16"
|
|
84
84
|
}
|
|
85
85
|
}
|
package/src/app-mcp.ts
CHANGED
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
// every app ever scaffolded (measured 2026-09-05). The contract is one file: `apps/<app>/mcp.ts`
|
|
6
6
|
// exports `mcp` — one `AppMcp`, or an array of them, one per population (customers, staff,
|
|
7
7
|
// affiliates) — this module finds it, and both boots mount what it carries: EVERY endpoint at its
|
|
8
|
-
// own `defineAppMcp({ path })`, the one path its RFC 9728 metadata and its 401 also name
|
|
8
|
+
// own `defineAppMcp({ path })`, the one path its RFC 9728 metadata and its 401 also name — and
|
|
9
|
+
// every app directory's file mounts, so an admin surface's `apps/admin/mcp.ts` stands beside the web one.
|
|
9
10
|
|
|
10
11
|
// why: a directory's existence — `Bun.file().exists()` answers for files, and `apps/` is a directory.
|
|
11
12
|
import { existsSync } from 'node:fs';
|
|
@@ -16,6 +17,7 @@ import type { Route } from '@ultimat3/http';
|
|
|
16
17
|
import { jsonResponse, originListed, selfOrigin } from '@ultimat3/http';
|
|
17
18
|
import {
|
|
18
19
|
type AppMcp,
|
|
20
|
+
assertIncludeCurrent,
|
|
19
21
|
assertRecipeLinks,
|
|
20
22
|
McpAppUnmountedError,
|
|
21
23
|
McpPathDuplicateError,
|
|
@@ -75,22 +77,44 @@ function endpointsOf(exported: unknown): readonly AppMcp[] | undefined {
|
|
|
75
77
|
return exported.every(isAppMcp) ? (exported as readonly AppMcp[]) : undefined;
|
|
76
78
|
}
|
|
77
79
|
|
|
78
|
-
/**
|
|
80
|
+
/** The web surface's file: endpoint #0 — the default — wherever its directory sorts. */
|
|
81
|
+
const DEFAULT_MCP_FILE = 'apps/web/mcp.ts';
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Every `apps/<app>/mcp.ts`, app-root-relative: the web surface's first, then the rest sorted, so
|
|
85
|
+
* two machines answer in one order and an `apps/admin/mcp.ts` never takes the default endpoint.
|
|
86
|
+
*/
|
|
79
87
|
async function candidates(root: string): Promise<readonly string[]> {
|
|
80
88
|
// A root with no `apps/` is an app with no MCP file, never a boot failure — the scan's ENOENT
|
|
81
89
|
// is answered as "none", and the warning below says which file to write.
|
|
82
90
|
if (!existsSync(join(root, 'apps'))) return [];
|
|
83
91
|
const files: string[] = [];
|
|
84
92
|
for await (const file of new Bun.Glob(APP_MCP_GLOB).scan({ cwd: root })) files.push(file);
|
|
85
|
-
|
|
93
|
+
files.sort();
|
|
94
|
+
return files.includes(DEFAULT_MCP_FILE)
|
|
95
|
+
? [DEFAULT_MCP_FILE, ...files.filter((file) => file !== DEFAULT_MCP_FILE)]
|
|
96
|
+
: files;
|
|
86
97
|
}
|
|
87
98
|
|
|
88
99
|
const NOTHING: AppMcpMount = { routes: [], path: null, paths: [], warning: undefined };
|
|
89
100
|
|
|
101
|
+
/** One mount file's endpoints. */
|
|
102
|
+
interface MountFile {
|
|
103
|
+
readonly file: string;
|
|
104
|
+
readonly endpoints: readonly AppMcp[];
|
|
105
|
+
}
|
|
106
|
+
|
|
90
107
|
/**
|
|
91
108
|
* The routes to mount, or the reason there are none. Pure over the filesystem it is pointed at;
|
|
92
109
|
* `mountAppMcp` below is the one place the warning becomes a log line.
|
|
93
110
|
*
|
|
111
|
+
* EVERY app directory's `mcp.ts` mounts — an admin surface's `apps/admin/mcp.ts` beside the web
|
|
112
|
+
* one — on one route table. The first sorted file used to win alone, so an admin surface's file
|
|
113
|
+
* silently took the app's `/mcp` away. Each is imported HERE, after the boot scan: the scan
|
|
114
|
+
* evaluates `apps/<app>/mcp.ts` after every `apps/<app>/{site,app,api,shared}/` module, so an
|
|
115
|
+
* `include: 'exposed'` built at its top level sees every primitive — and one built earlier, in a
|
|
116
|
+
* module it re-exports, is `X_MCP_INCLUDE_EARLY` rather than a catalog missing tools.
|
|
117
|
+
*
|
|
94
118
|
* `meta.auth: 'public'` and `enforcedBy: 'handler'` — the http pipeline must not pre-judge:
|
|
95
119
|
* `mcp.route.handle` is the one evaluation, and it reads `Authorization: Bearer` through the
|
|
96
120
|
* `resolveToken` the app gave `defineAppMcp`, then decides per tool through the same policy every
|
|
@@ -100,67 +124,78 @@ const NOTHING: AppMcpMount = { routes: [], path: null, paths: [], warning: undef
|
|
|
100
124
|
export async function appMcpMount(root: string): Promise<AppMcpMount> {
|
|
101
125
|
if (!(await exposed(root))) return NOTHING;
|
|
102
126
|
const files = await candidates(root);
|
|
103
|
-
const
|
|
127
|
+
const mounted: MountFile[] = [];
|
|
104
128
|
for (const file of files) {
|
|
105
129
|
const module = (await import(join(root, file))) as Record<string, unknown>;
|
|
106
130
|
const endpoints = endpointsOf(module[APP_MCP_EXPORT]);
|
|
107
131
|
if (endpoints === undefined) continue;
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
assertRecipeLinks(servedRecipes());
|
|
111
|
-
return mountEndpoints(endpoints, file);
|
|
132
|
+
for (const endpoint of endpoints) assertIncludeCurrent(endpoint, file);
|
|
133
|
+
mounted.push({ file, endpoints });
|
|
112
134
|
}
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
135
|
+
if (mounted.length === 0) {
|
|
136
|
+
return {
|
|
137
|
+
...NOTHING,
|
|
138
|
+
warning: new McpAppUnmountedError({
|
|
139
|
+
reason: 'missing',
|
|
140
|
+
path: UNROUTED_MCP_PATH,
|
|
141
|
+
file: files[0] ?? DEFAULT_MCP_FILE,
|
|
142
|
+
}),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
// Every endpoint is built by now: an error's declared `docs://recipes/` link must name a recipe
|
|
146
|
+
// one of them serves (`X_MCP_RECIPE_UNKNOWN`), the union and never one endpoint alone.
|
|
147
|
+
assertRecipeLinks(servedRecipes());
|
|
148
|
+
return mountEndpoints(mounted);
|
|
121
149
|
}
|
|
122
150
|
|
|
123
151
|
/**
|
|
124
|
-
*
|
|
152
|
+
* Every file's endpoints onto one route table. An endpoint built without `resolveToken` has no
|
|
125
153
|
* route: it is skipped and the FIRST such is the warning, so one unfinished population never takes
|
|
126
154
|
* the others down with it. Two endpoints on one route is thrown — see `McpPathDuplicateError`.
|
|
155
|
+
* Numbering: `#n` in a message is the endpoint's index in its own file's export; only the very
|
|
156
|
+
* first endpoint mounted is the default (#0 of the route names and the root metadata document).
|
|
127
157
|
*/
|
|
128
|
-
function mountEndpoints(
|
|
158
|
+
function mountEndpoints(files: readonly MountFile[]): AppMcpMount {
|
|
129
159
|
const routes: Route[] = [];
|
|
130
160
|
const paths: string[] = [];
|
|
131
|
-
const claimed = new Map<string, number>();
|
|
161
|
+
const claimed = new Map<string, { readonly file: string; readonly index: number }>();
|
|
132
162
|
let warning: McpAppUnmountedError | undefined;
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
});
|
|
142
|
-
return;
|
|
143
|
-
}
|
|
144
|
-
// The descriptor's own path — the one its metadata, its `resourceUrl` and its 401 were built
|
|
145
|
-
// over. A mount path from anywhere else is a resource advertised where nothing answers.
|
|
146
|
-
const path = route.path;
|
|
147
|
-
for (const mounted of endpointRoutes(route, path, index)) {
|
|
148
|
-
const key = `${mounted.method} ${mounted.path}`;
|
|
149
|
-
const first = claimed.get(key);
|
|
150
|
-
if (first !== undefined) {
|
|
151
|
-
throw new McpPathDuplicateError({
|
|
152
|
-
method: mounted.method,
|
|
153
|
-
path: mounted.path,
|
|
154
|
-
endpoints: [first, index],
|
|
163
|
+
let mountedIndex = 0;
|
|
164
|
+
for (const { file, endpoints } of files) {
|
|
165
|
+
endpoints.forEach((endpoint, index) => {
|
|
166
|
+
const route = endpoint.route;
|
|
167
|
+
if (route === undefined) {
|
|
168
|
+
warning ??= new McpAppUnmountedError({
|
|
169
|
+
reason: 'no-route',
|
|
170
|
+
path: UNROUTED_MCP_PATH,
|
|
155
171
|
file,
|
|
172
|
+
...(index === 0 ? {} : { endpoint: index }),
|
|
156
173
|
});
|
|
174
|
+
return;
|
|
157
175
|
}
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
176
|
+
// The descriptor's own path — the one its metadata, its `resourceUrl` and its 401 were built
|
|
177
|
+
// over. A mount path from anywhere else is a resource advertised where nothing answers.
|
|
178
|
+
const path = route.path;
|
|
179
|
+
for (const one of endpointRoutes(route, path, mountedIndex)) {
|
|
180
|
+
const key = `${one.method} ${one.path}`;
|
|
181
|
+
const first = claimed.get(key);
|
|
182
|
+
if (first !== undefined) {
|
|
183
|
+
throw new McpPathDuplicateError({
|
|
184
|
+
method: one.method,
|
|
185
|
+
path: one.path,
|
|
186
|
+
endpoints: [first.index, index],
|
|
187
|
+
file,
|
|
188
|
+
firstFile: first.file,
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
claimed.set(key, { file, index });
|
|
192
|
+
routes.push(one);
|
|
193
|
+
}
|
|
194
|
+
paths.push(path);
|
|
195
|
+
mountedIndex += 1;
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
return { routes, path: paths[0] ?? null, paths, warning };
|
|
164
199
|
}
|
|
165
200
|
|
|
166
201
|
type Descriptor = NonNullable<AppMcp['route']>;
|
package/src/cmd-upgrade-spec.ts
CHANGED
|
@@ -15,7 +15,7 @@ export const upgradeSpec: CommandSpec = {
|
|
|
15
15
|
name: 'to',
|
|
16
16
|
type: 'string',
|
|
17
17
|
summary:
|
|
18
|
-
'an exact release inside the next major,
|
|
18
|
+
'an exact release inside the next major; over a mixed set, inside the lowest pins’ next major, and only those pins move (default: the next major’s newest)',
|
|
19
19
|
},
|
|
20
20
|
{
|
|
21
21
|
name: 'dry-run',
|
package/src/island-bundle.ts
CHANGED
|
@@ -189,7 +189,9 @@ async function buildAll(
|
|
|
189
189
|
// gets the same rule (`island-package-dedupe.ts`): a nested copy is a second module where no
|
|
190
190
|
// symlink folds it, which is every `file:` install on Windows.
|
|
191
191
|
plugins: [
|
|
192
|
-
...(runtime === undefined
|
|
192
|
+
...(runtime === undefined
|
|
193
|
+
? []
|
|
194
|
+
: [islandRealtimePlugin(root, runtime, follows, live.length === files.length)]),
|
|
193
195
|
solidDedupePlugin(root),
|
|
194
196
|
...frameworkDedupePlugins(root),
|
|
195
197
|
solidJsxPlugin,
|
package/src/island-realtime.ts
CHANGED
|
@@ -5,8 +5,9 @@
|
|
|
5
5
|
// that reads the record store or the outbox also waits for the page's restore before it mounts
|
|
6
6
|
// (#506); one that only follows the socket does not (`island-realtime-state.ts`).
|
|
7
7
|
|
|
8
|
-
// why: Bun ships no path API; the entry is joined to the root, the runtime named by its basename
|
|
9
|
-
|
|
8
|
+
// why: Bun ships no path API; the entry is joined to the root, the runtime named by its basename,
|
|
9
|
+
// core's transport found beside the core module importing it.
|
|
10
|
+
import { basename, dirname, join } from 'node:path';
|
|
10
11
|
import type { BunPlugin } from 'bun';
|
|
11
12
|
import { followsOnly, type IslandRealtime } from './island-realtime-state';
|
|
12
13
|
import { RUNTIME_CHUNK_SPECIFIER, type RuntimeChunk } from './island-runtime';
|
|
@@ -29,6 +30,10 @@ const INSTALL = 'ultimate:island-realtime';
|
|
|
29
30
|
const RESTORE = 'ultimate:island-restore';
|
|
30
31
|
const MODULE = 'ultimate:island-module:';
|
|
31
32
|
const WAIT = 'ultimate:island-page-wait';
|
|
33
|
+
const CORE_TRANSPORT = 'ultimate:island-core-transport';
|
|
34
|
+
/** What core's own modules import the transport by — the one specifier the swap answers. */
|
|
35
|
+
const TRANSPORT_SPECIFIER = /^\.\/client-transport$/;
|
|
36
|
+
const TRANSPORT_MODULE = 'transport:';
|
|
32
37
|
|
|
33
38
|
/**
|
|
34
39
|
* Whether the island's own graph value-imports realtime. Relative specifiers only, which is the
|
|
@@ -108,6 +113,41 @@ const restoreSource = (): string =>
|
|
|
108
113
|
`import { holdFirstPaint } from '${REALTIME}';\n` +
|
|
109
114
|
`await holdFirstPaint();\n`;
|
|
110
115
|
|
|
116
|
+
/**
|
|
117
|
+
* Core's `client-transport.ts`, as a realtime island's bundle sees it: `clientTransport` sends
|
|
118
|
+
* through the page runtime's copy (`runtimeSend`), and `settleAnswer` — `clientStream`'s, which
|
|
119
|
+
* the runtime does not expose — is the real one, tree-shaken to what it needs. The island awaited
|
|
120
|
+
* the runtime before its module body ran, so a send can never find it absent. Measured on
|
|
121
|
+
* `examples/dummy`'s run console, As of 2026-11-07: see `island-transport.test.ts`'s header.
|
|
122
|
+
*/
|
|
123
|
+
const transportSource = (): string =>
|
|
124
|
+
`import { runtimeSend } from '${WAIT}';\n` +
|
|
125
|
+
`export { settleAnswer } from '${CORE_TRANSPORT}';\n` +
|
|
126
|
+
`export function clientTransport(req) {\n return runtimeSend(req);\n}\n`;
|
|
127
|
+
|
|
128
|
+
/** Whether `file` is a source file of an installed `@ultimat3/core` — any copy of it. */
|
|
129
|
+
async function inCore(file: string, cache: Map<string, Promise<boolean>>): Promise<boolean> {
|
|
130
|
+
const dir = dirname(file);
|
|
131
|
+
let known = cache.get(dir);
|
|
132
|
+
if (known === undefined) {
|
|
133
|
+
known = packageNameAbove(dir).then((name) => name === '@ultimat3/core');
|
|
134
|
+
cache.set(dir, known);
|
|
135
|
+
}
|
|
136
|
+
return await known;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function packageNameAbove(start: string): Promise<string | undefined> {
|
|
140
|
+
for (let dir = start; dir !== dirname(dir); dir = dirname(dir)) {
|
|
141
|
+
const manifest = Bun.file(join(dir, 'package.json'));
|
|
142
|
+
if (!(await manifest.exists())) continue;
|
|
143
|
+
const parsed: unknown = await manifest.json();
|
|
144
|
+
const name =
|
|
145
|
+
typeof parsed === 'object' && parsed !== null ? Reflect.get(parsed, 'name') : undefined;
|
|
146
|
+
return typeof name === 'string' ? name : undefined;
|
|
147
|
+
}
|
|
148
|
+
return undefined;
|
|
149
|
+
}
|
|
150
|
+
|
|
111
151
|
/**
|
|
112
152
|
* `runtime` names the realtime every bare `@ultimat3/realtime` of the build resolves to — the copy
|
|
113
153
|
* the runtime chunk was built against, resolved from the first realtime island's directory. One
|
|
@@ -115,15 +155,36 @@ const restoreSource = (): string =>
|
|
|
115
155
|
* sets is only read by hooks from the same module instance, and the page object it reads is the
|
|
116
156
|
* shape that copy's runtime installs. `follows` is THIS build's islands that restore nothing: an
|
|
117
157
|
* island absent from it waits for the restore, which is the answer that can never paint stale.
|
|
158
|
+
*
|
|
159
|
+
* `sendThroughRuntime` swaps core's transport for the runtime's (`transportSource`). Only for a
|
|
160
|
+
* build whose every entry is a realtime island: one split build shares a module between its
|
|
161
|
+
* entries, and a plain island never waits for a runtime to send through.
|
|
118
162
|
*/
|
|
119
163
|
export function islandRealtimePlugin(
|
|
120
164
|
root: string,
|
|
121
165
|
runtime: RuntimeChunk,
|
|
122
166
|
follows: ReadonlySet<string> = new Set(),
|
|
167
|
+
sendThroughRuntime = false,
|
|
123
168
|
): BunPlugin {
|
|
169
|
+
const cores = new Map<string, Promise<boolean>>();
|
|
124
170
|
return {
|
|
125
171
|
name: 'ultimate-island-realtime',
|
|
126
172
|
setup(build) {
|
|
173
|
+
if (sendThroughRuntime) {
|
|
174
|
+
// Only core's own `./client-transport` — an app file of that name is the app's. The real
|
|
175
|
+
// file rides in the namespace path, so the module's SOURCE names no path on this machine.
|
|
176
|
+
build.onResolve({ filter: TRANSPORT_SPECIFIER }, async (args) =>
|
|
177
|
+
args.namespace === 'file' && (await inCore(args.importer, cores))
|
|
178
|
+
? {
|
|
179
|
+
path: `${TRANSPORT_MODULE}${join(dirname(args.importer), 'client-transport.ts')}`,
|
|
180
|
+
namespace: NAMESPACE,
|
|
181
|
+
}
|
|
182
|
+
: undefined,
|
|
183
|
+
);
|
|
184
|
+
build.onResolve({ filter: /^ultimate:island-core-transport$/ }, (args) => ({
|
|
185
|
+
path: args.importer.slice(TRANSPORT_MODULE.length),
|
|
186
|
+
}));
|
|
187
|
+
}
|
|
127
188
|
build.onResolve({ filter: /^ultimate:island-entry:/ }, (args) => ({
|
|
128
189
|
path: `entry:${args.path.slice(REALTIME_ISLAND_ENTRY.length)}`,
|
|
129
190
|
namespace: NAMESPACE,
|
|
@@ -158,7 +219,9 @@ export function islandRealtimePlugin(
|
|
|
158
219
|
)
|
|
159
220
|
: args.path === 'restore'
|
|
160
221
|
? restoreSource()
|
|
161
|
-
:
|
|
222
|
+
: args.path.startsWith(TRANSPORT_MODULE)
|
|
223
|
+
? transportSource()
|
|
224
|
+
: installSource(runtime.url),
|
|
162
225
|
loader: 'js',
|
|
163
226
|
}));
|
|
164
227
|
},
|
package/src/role-start.ts
CHANGED
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
outboxRelay,
|
|
27
27
|
postgresLeaseLeader,
|
|
28
28
|
postgresSchedulerState,
|
|
29
|
+
registeredJobs,
|
|
29
30
|
} from '@ultimat3/jobs';
|
|
30
31
|
import type { SyncWs } from '@ultimat3/realtime/server';
|
|
31
32
|
import { errorPageHook } from './error-pages';
|
|
@@ -45,6 +46,7 @@ import { prepareSync, type RunningSync, syncReadsChanges } from './role-sync';
|
|
|
45
46
|
import { startWorkerWake } from './role-wake';
|
|
46
47
|
import { devHooks } from './runtime-hooks';
|
|
47
48
|
import { workerOptionsFor } from './runtime-jobs';
|
|
49
|
+
import { reportUnservedQueues } from './runtime-jobs-unserved';
|
|
48
50
|
import { startLiveFeed } from './runtime-live-feed';
|
|
49
51
|
import { type RunningServices, releaseOrThrow } from './runtime-services';
|
|
50
52
|
import { inlineScriptSources } from './script-csp';
|
|
@@ -335,16 +337,30 @@ export async function startRoles(options: StartRolesOptions): Promise<RunningRol
|
|
|
335
337
|
prepared === null ? null : await prepared.listen(server === null ? null : server.url());
|
|
336
338
|
if (sync !== null) releaseSync = sync.stop;
|
|
337
339
|
|
|
338
|
-
const
|
|
339
|
-
?
|
|
340
|
-
driver: options.runtime.jobs,
|
|
341
|
-
build: options.buildId,
|
|
342
|
-
context: () => ctxOf({ role: 'worker', buildId: options.buildId }),
|
|
343
|
-
...workerOptionsFor(options.runtime.workerConfig),
|
|
344
|
-
})
|
|
340
|
+
const workerOptions = selected.includes('worker')
|
|
341
|
+
? workerOptionsFor(options.runtime.workerConfig)
|
|
345
342
|
: null;
|
|
343
|
+
const worker =
|
|
344
|
+
workerOptions === null
|
|
345
|
+
? null
|
|
346
|
+
: jobWorker({
|
|
347
|
+
driver: options.runtime.jobs,
|
|
348
|
+
build: options.buildId,
|
|
349
|
+
context: () => ctxOf({ role: 'worker', buildId: options.buildId }),
|
|
350
|
+
...workerOptions,
|
|
351
|
+
});
|
|
346
352
|
worker?.start();
|
|
347
353
|
if (worker !== null) started.push(() => worker.stop('x dev stopped'));
|
|
354
|
+
// An exact worker (`WORKER_QUEUES`) names the registered queues NO live worker serves — after
|
|
355
|
+
// its start, so the registry holds its own row and the rest of the fleet's. Never rejects.
|
|
356
|
+
if (workerOptions !== null && options.runtime.workerConfig?.exact === true) {
|
|
357
|
+
const introspect = options.runtime.jobs.introspect;
|
|
358
|
+
void reportUnservedQueues({
|
|
359
|
+
served: workerOptions.queues ?? [],
|
|
360
|
+
registered: registeredJobs().map((handle) => handle.queue),
|
|
361
|
+
workers: introspect === undefined ? undefined : () => introspect.workers(),
|
|
362
|
+
});
|
|
363
|
+
}
|
|
348
364
|
|
|
349
365
|
// The half of the transactional outbox that makes a staged row a running job. Without it
|
|
350
366
|
// `handle.enqueue()` inside a transaction writes to `x_outbox` and nothing ever reads it back
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// An exact worker's (`WORKER_QUEUES`) one boot line about the registered queues it leaves: warned
|
|
2
|
+
// only for those NO live worker in the registry serves. Another Deployment serving the rest is the
|
|
3
|
+
// whole point of the split, and a warning at every boot of a correct fleet is noise that hides the
|
|
4
|
+
// one boot where a queue really has no worker.
|
|
5
|
+
|
|
6
|
+
import { logger, renderThrowable } from '@ultimat3/core';
|
|
7
|
+
import type { WorkerRecord } from '@ultimat3/jobs';
|
|
8
|
+
|
|
9
|
+
export interface UnservedQueuesInput {
|
|
10
|
+
/** What this worker claims. */
|
|
11
|
+
readonly served: readonly string[];
|
|
12
|
+
/** Every queue a registered job is enqueued on. */
|
|
13
|
+
readonly registered: readonly string[];
|
|
14
|
+
/** The fleet's live rows (`introspect.workers`); `undefined` on a driver with no registry. */
|
|
15
|
+
readonly workers: (() => Promise<readonly WorkerRecord[]>) | undefined;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const FIX =
|
|
19
|
+
'x jobs list --json — every queue listed with no worker needs one: WORKER_QUEUES=<queue>,<queue> on another worker Deployment, or add it to this one';
|
|
20
|
+
|
|
21
|
+
/** Never rejects: a boot line is advice, and a failed read of it must not fail the boot. */
|
|
22
|
+
export async function reportUnservedQueues(input: UnservedQueuesInput): Promise<void> {
|
|
23
|
+
const left = [...new Set(input.registered)].filter((q) => !input.served.includes(q)).sort();
|
|
24
|
+
if (left.length === 0) return;
|
|
25
|
+
// Unknowable here: said at info, because the split may well be served elsewhere.
|
|
26
|
+
const unknown = (reason: string): void =>
|
|
27
|
+
logger.info('jobs.worker.queue-unserved', {
|
|
28
|
+
queues: left,
|
|
29
|
+
served: input.served,
|
|
30
|
+
checked: false,
|
|
31
|
+
reason,
|
|
32
|
+
fix: FIX,
|
|
33
|
+
});
|
|
34
|
+
if (input.workers === undefined) return unknown('this driver keeps no worker registry');
|
|
35
|
+
let rows: readonly WorkerRecord[];
|
|
36
|
+
try {
|
|
37
|
+
rows = await input.workers();
|
|
38
|
+
} catch (error) {
|
|
39
|
+
return unknown(renderThrowable(error));
|
|
40
|
+
}
|
|
41
|
+
// A draining worker is leaving: it claims nothing new, so it serves no queue.
|
|
42
|
+
const covered = new Set(rows.filter((one) => one.draining !== true).flatMap((one) => one.queues));
|
|
43
|
+
const queues = left.filter((queue) => !covered.has(queue));
|
|
44
|
+
if (queues.length === 0) return;
|
|
45
|
+
logger.warn('jobs.worker.queue-unserved', { queues, served: input.served, fix: FIX });
|
|
46
|
+
}
|
package/src/runtime-jobs.ts
CHANGED
|
@@ -6,7 +6,6 @@
|
|
|
6
6
|
import type { AppConfig, JobsConcurrency } from '@ultimat3/core';
|
|
7
7
|
import {
|
|
8
8
|
ConfigInvalidError,
|
|
9
|
-
logger,
|
|
10
9
|
renderCauseValue,
|
|
11
10
|
renderFixShellArg,
|
|
12
11
|
UltimateError,
|
|
@@ -99,21 +98,6 @@ export function workerQueuesFor(
|
|
|
99
98
|
return served.length === 0 ? undefined : served;
|
|
100
99
|
}
|
|
101
100
|
|
|
102
|
-
/**
|
|
103
|
-
* An exact worker's one boot line about what it leaves: the queues registered jobs are enqueued
|
|
104
|
-
* on that it will never claim. A warning and not a refusal — another Deployment serving them is
|
|
105
|
-
* the whole point of the split — but this process cannot see that one, so it says what to check.
|
|
106
|
-
*/
|
|
107
|
-
function reportUnserved(served: readonly string[], registered: readonly string[]): void {
|
|
108
|
-
const queues = [...new Set(registered)].filter((queue) => !served.includes(queue)).sort();
|
|
109
|
-
if (queues.length === 0) return;
|
|
110
|
-
logger.warn('jobs.worker.queue-unserved', {
|
|
111
|
-
queues,
|
|
112
|
-
served,
|
|
113
|
-
fix: `x jobs list --json — every queue listed with no worker needs one: WORKER_QUEUES=<queue>,<queue> on another worker Deployment, or add it to this one's`,
|
|
114
|
-
});
|
|
115
|
-
}
|
|
116
|
-
|
|
117
101
|
/**
|
|
118
102
|
* A `WORKER_QUEUES` entry no registered job and no `jobs.queues` entry names. The worker would
|
|
119
103
|
* claim a queue nothing is enqueued on and leave the real ones waiting while its pod stays Ready —
|
|
@@ -183,7 +167,6 @@ export function workerOptionsFor(config: WorkerConfig | undefined): WorkerConfig
|
|
|
183
167
|
const exact = config?.exact === true;
|
|
184
168
|
if (exact) refuseUnknownQueues(config?.queues ?? [], config?.configured ?? [], registered);
|
|
185
169
|
const queues = workerQueuesFor(config?.queues ?? [], registered, exact);
|
|
186
|
-
if (exact) reportUnserved(queues ?? [], registered);
|
|
187
170
|
// Passed as written: `jobWorker` reads a table by own key and runs a queue it does not name at
|
|
188
171
|
// core's `JOBS_CONCURRENCY_DEFAULT` — the one slot default, so no second table is built here.
|
|
189
172
|
const concurrency = config?.concurrency;
|
|
@@ -3,9 +3,14 @@
|
|
|
3
3
|
// "the entity files this run wrote" asks here: three of them matched `/entity.ts` alone, so a
|
|
4
4
|
// second entity in a feature could be written by nothing and registered by nothing.
|
|
5
5
|
|
|
6
|
-
/**
|
|
6
|
+
/**
|
|
7
|
+
* `apps/<app>/<surface>/<feature>/entity.ts` or `…/entity-<name>.ts` — never a test beside it. A
|
|
8
|
+
* slice nests (`app/runs/canary/entity.ts`) as freely as any other feature directory does, so the
|
|
9
|
+
* feature is one segment or more: a depth-one rule refused a nested slice's entity at the package
|
|
10
|
+
* boundary while the app's own surface rules accepted the same file.
|
|
11
|
+
*/
|
|
7
12
|
const ENTITY_MODULE =
|
|
8
|
-
/^apps\/[^/]+\/(?<rest>[^/]
|
|
13
|
+
/^apps\/[^/]+\/(?<rest>[^/]+(?:\/[a-z0-9-]+)+)\/(?<stem>entity(?:-[a-z0-9-]+)?)\.ts$/;
|
|
9
14
|
|
|
10
15
|
/** Whether `path` is a module a generator declares an entity in. */
|
|
11
16
|
export const isEntityModule = (path: string): boolean => ENTITY_MODULE.test(path);
|
|
@@ -111,7 +111,9 @@ const clientTest = (
|
|
|
111
111
|
): string => `// Which store the handle reads: memory under \`bun test\`, the process's Postgres client
|
|
112
112
|
// everywhere else. A handle that silently took the memory driver in a container would serve an
|
|
113
113
|
// empty database with a green gate.
|
|
114
|
+
import { ctxOf, runWithContext } from '@ultimat3/core';
|
|
114
115
|
import { recordingClient, setDbClient } from '@ultimat3/db';
|
|
116
|
+
import { testActor } from '@ultimat3/policy';
|
|
115
117
|
import { expect, unitTest } from '@ultimat3/testing';
|
|
116
118
|
import { db, driver, selectDriver } from './client';
|
|
117
119
|
|
|
@@ -123,9 +125,27 @@ unitTest('${app.kebab} reads memory under test and Postgres everywhere else', ()
|
|
|
123
125
|
expect(selectDriver({ ULTIMATE_ENV: 'development' }).reset).toBeUndefined();
|
|
124
126
|
});
|
|
125
127
|
|
|
126
|
-
|
|
128
|
+
// Every table on \`db\` reads through \`driver\`: a client installed under it sees each table's
|
|
129
|
+
// statement. \`database(entities)\` with no driver takes the process default — memory — and would
|
|
130
|
+
// answer every test from rows no migration made while this file stayed green. The reads run as an
|
|
131
|
+
// actor because a tenant table refuses one with no org to scope it (\`X_TENANCY_UNSCOPED\`).
|
|
132
|
+
unitTest('the handle is built over the driver this module exports', async () => {
|
|
127
133
|
expect(driver.reset).toBeDefined();
|
|
128
|
-
|
|
134
|
+
const tables: readonly { all(): Promise<readonly unknown[]> }[] = Object.values(db);
|
|
135
|
+
const { actor } = testActor('client-test', { orgId: '00000000-0000-4000-8000-000000000001' });
|
|
136
|
+
const client = recordingClient();
|
|
137
|
+
setDbClient(client);
|
|
138
|
+
try {
|
|
139
|
+
await runWithContext(ctxOf({ actor }), async () => {
|
|
140
|
+
for (const table of tables) {
|
|
141
|
+
const before = client.statements.length;
|
|
142
|
+
await table.all();
|
|
143
|
+
expect(client.statements.length).toBeGreaterThan(before);
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
} finally {
|
|
147
|
+
setDbClient(undefined);
|
|
148
|
+
}
|
|
129
149
|
});
|
|
130
150
|
|
|
131
151
|
// The test driver decides per call: a migrated database installed with \`setDbClient\` is what
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// The admin surface's own MCP endpoint, as `x new` writes it: `apps/admin/mcp.ts` — the file the
|
|
2
|
+
// mount reads for that app directory (`app-mcp.ts`) — serving the admin catalog and the app's
|
|
3
|
+
// exposed tools at `/admin/mcp`, behind the web surface's agent-token resolver. Mounted beside
|
|
4
|
+
// `apps/web/mcp.ts`, and REFUSING without a token, like it.
|
|
5
|
+
|
|
6
|
+
import { sortedImports } from './imports';
|
|
7
|
+
import type { GeneratedFile, NameSet } from './naming';
|
|
8
|
+
|
|
9
|
+
const endpoint = (
|
|
10
|
+
app: NameSet,
|
|
11
|
+
): string => `// The admin surface's MCP endpoint: \`POST /admin/mcp\`, mounted beside \`apps/web/mcp.ts\` by \`x dev\`
|
|
12
|
+
// and the web role, which mount every \`apps/<app>/mcp.ts\` by its path and its \`mcp\` export. Staff
|
|
13
|
+
// agents get the admin's own tools (\`admin.<entity>.list\`, …) AND the app's exposed actions and
|
|
14
|
+
// queries (\`include: 'exposed'\`), each gated by the actor's permissions and the token's scope.
|
|
15
|
+
//
|
|
16
|
+
// Built HERE, at module scope, and nowhere under \`apps/admin/app/\`: the boot scan evaluates this
|
|
17
|
+
// file after every \`apps/*/{site,app,api,shared}\` module has registered its primitives, and the mount
|
|
18
|
+
// refuses a catalog projected before that (X_MCP_INCLUDE_EARLY) rather than serve it short.
|
|
19
|
+
${sortedImports([
|
|
20
|
+
`import { type AgentCaller, resolveAgentToken } from '@${app.kebab}/web/app/auth/agent-token';`,
|
|
21
|
+
"import { type AdminMcpActor, adminMcp } from '@ultimat3/admin';",
|
|
22
|
+
])}
|
|
23
|
+
import { admin } from './app/admin/admin';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The admin's actor for the agent the app's resolver answered — the same agent \`/mcp\` serves,
|
|
27
|
+
* tenant and grants included. A key issued with scopes is held to its \`admin:*\` ones (a key
|
|
28
|
+
* scoped \`posts:write\` alone may call nothing here); a key with none is as strong as its actor.
|
|
29
|
+
*/
|
|
30
|
+
export const adminAgentOf = (caller: AgentCaller | null): AdminMcpActor | null => {
|
|
31
|
+
if (caller === null) return null;
|
|
32
|
+
const { id, roles, permissions, orgId } = caller.actor;
|
|
33
|
+
return {
|
|
34
|
+
id,
|
|
35
|
+
roles,
|
|
36
|
+
permissions,
|
|
37
|
+
...(orgId === undefined ? {} : { orgId }),
|
|
38
|
+
...(caller.scopes.size === 0 ? {} : { tokenScopes: [...caller.scopes] }),
|
|
39
|
+
};
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export const mcp = adminMcp({
|
|
43
|
+
app: admin,
|
|
44
|
+
path: '/admin/mcp',
|
|
45
|
+
include: 'exposed',
|
|
46
|
+
// The app's one resolver: an issued API key in every environment, \`dev-agent.<role>\` under
|
|
47
|
+
// \`x dev\` from this machine alone — decided on how the request arrived (\`facts\`).
|
|
48
|
+
actor: async ({ token, facts }) =>
|
|
49
|
+
adminAgentOf(token === undefined ? null : await resolveAgentToken(token, facts)),
|
|
50
|
+
});
|
|
51
|
+
`;
|
|
52
|
+
|
|
53
|
+
const endpointTest = (
|
|
54
|
+
app: NameSet,
|
|
55
|
+
): string => `// The admin endpoint is mounted and refusing: a route at /admin/mcp, and a 401 for a request that
|
|
56
|
+
// carries no token or one this process does not honour — before any tool, or the body, is read.
|
|
57
|
+
${sortedImports([
|
|
58
|
+
`import { DEV_AGENT_TOKEN_PREFIX } from '@${app.kebab}/web/app/auth/agent-token';`,
|
|
59
|
+
`import { DEMO_ORG_ID } from '@${app.kebab}/web/shared/demo-org';`,
|
|
60
|
+
"import { agentActor } from '@ultimat3/core';",
|
|
61
|
+
"import { expect, unitTest } from '@ultimat3/testing';",
|
|
62
|
+
])}
|
|
63
|
+
import { adminAgentOf, mcp } from './mcp';
|
|
64
|
+
|
|
65
|
+
const post = (headers: Record<string, string>) =>
|
|
66
|
+
new Request('http://localhost/admin/mcp', { method: 'POST', headers, body: '{}' });
|
|
67
|
+
|
|
68
|
+
unitTest('POST /admin/mcp is mounted, and answers 401 without a token', async () => {
|
|
69
|
+
const route = mcp.route;
|
|
70
|
+
if (route === undefined) return expect.unreachable('apps/admin/mcp.ts built no route');
|
|
71
|
+
expect(route.path).toBe('/admin/mcp');
|
|
72
|
+
const response = await route.handle(post({}));
|
|
73
|
+
expect(response.status).toBe(401);
|
|
74
|
+
// The development token is refused outside \`x dev\`: \`bun test\` is not development.
|
|
75
|
+
const dev = await route.handle(post({ authorization: \`Bearer \${DEV_AGENT_TOKEN_PREFIX}admin\` }));
|
|
76
|
+
expect(dev.status).toBe(401);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
unitTest('the agent /mcp resolves is the admin actor, tenant kept and scopes held', () => {
|
|
80
|
+
const actor = agentActor({ id: 'k1', orgId: DEMO_ORG_ID, roles: ['admin'] });
|
|
81
|
+
expect(adminAgentOf(null)).toBeNull();
|
|
82
|
+
// No scopes on the key: as strong as its actor, whose own grants still decide each tool.
|
|
83
|
+
expect(adminAgentOf({ actor, scopes: new Set() })).toEqual({
|
|
84
|
+
id: 'k1',
|
|
85
|
+
roles: ['admin'],
|
|
86
|
+
permissions: [],
|
|
87
|
+
orgId: DEMO_ORG_ID,
|
|
88
|
+
});
|
|
89
|
+
expect(adminAgentOf({ actor, scopes: new Set(['admin:read']) })?.tokenScopes).toEqual([
|
|
90
|
+
'admin:read',
|
|
91
|
+
]);
|
|
92
|
+
});
|
|
93
|
+
`;
|
|
94
|
+
|
|
95
|
+
/** `apps/admin/mcp.ts` and the test that pins it mounted and refusing. */
|
|
96
|
+
export const mcpAdminFiles = (app: NameSet): readonly GeneratedFile[] => [
|
|
97
|
+
{ path: 'apps/admin/mcp.ts', contents: endpoint(app) },
|
|
98
|
+
{ path: 'apps/admin/mcp.test.ts', contents: endpointTest(app) },
|
|
99
|
+
];
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
// The generated app's `packages/mcp`: the app's own MCP surface, projected straight from the
|
|
2
2
|
// action registry rather than re-listed, plus the test that pins every projected tool describing
|
|
3
|
-
// itself — and the `apps/web/mcp.ts`
|
|
3
|
+
// itself — and the endpoints that mount it: `apps/web/mcp.ts` (`scaffold-mcp-endpoint.ts`) and the
|
|
4
|
+
// admin surface's `apps/admin/mcp.ts` (`scaffold-mcp-admin.ts`).
|
|
4
5
|
|
|
5
6
|
import type { GeneratedFile, NameSet } from './naming';
|
|
7
|
+
import { mcpAdminFiles } from './scaffold-mcp-admin';
|
|
6
8
|
import { mcpEndpointFiles } from './scaffold-mcp-endpoint';
|
|
7
9
|
import { packageShapeFiles, workspacePackageJson } from './scaffold-package-shape';
|
|
8
10
|
|
|
@@ -77,7 +79,7 @@ contractTest('the app exposes its actions as MCP tools', () => {
|
|
|
77
79
|
});
|
|
78
80
|
`;
|
|
79
81
|
|
|
80
|
-
/** Every file the `packages/mcp` workspace ships, then the
|
|
82
|
+
/** Every file the `packages/mcp` workspace ships, then the endpoints that mount it: web's, admin's. */
|
|
81
83
|
export const mcpPackageFiles = (app: NameSet): readonly GeneratedFile[] => [
|
|
82
84
|
{ path: 'packages/mcp/package.json', contents: workspacePackageJson(app, 'mcp', DESCRIPTION) },
|
|
83
85
|
...packageShapeFiles(app, 'mcp', DESCRIPTION),
|
|
@@ -85,4 +87,5 @@ export const mcpPackageFiles = (app: NameSet): readonly GeneratedFile[] => [
|
|
|
85
87
|
{ path: 'packages/mcp/src/index.test.ts', contents: mcpUnitTest() },
|
|
86
88
|
{ path: 'packages/mcp/src/index.contract.test.ts', contents: mcpTest() },
|
|
87
89
|
...mcpEndpointFiles(app),
|
|
90
|
+
...mcpAdminFiles(app),
|
|
88
91
|
];
|
package/src/upgrade-pins.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// why: Bun exposes no path-join primitive; Bun.file takes one already joined.
|
|
6
6
|
import { join } from 'node:path';
|
|
7
7
|
import { UltimateError } from '@ultimat3/core';
|
|
8
|
-
import {
|
|
8
|
+
import { pinMoves, type UpgradePin } from './upgrade-plan';
|
|
9
9
|
import { workspaceGlobs, workspaceManifestPaths } from './workspace-graph';
|
|
10
10
|
|
|
11
11
|
const ROOT_MANIFEST = 'package.json';
|
|
@@ -53,11 +53,12 @@ export function readPins(manifests: readonly WorkspaceManifest[]): readonly Upgr
|
|
|
53
53
|
}
|
|
54
54
|
|
|
55
55
|
/**
|
|
56
|
-
* Every pin at the exact version, the rest of the file byte-identical. Exact whatever
|
|
57
|
-
* caret is how `^19.0.0` beside `^19.4.0` came to be mixed, and a range is a silent
|
|
58
|
-
* A
|
|
56
|
+
* Every pin the hop moves at the exact version, the rest of the file byte-identical. Exact whatever
|
|
57
|
+
* it was: a caret is how `^19.0.0` beside `^19.4.0` came to be mixed, and a range is a silent
|
|
58
|
+
* upgrade. A pin above `to` is a workspace a mixed set has further on and stays (`pinMoves`); a
|
|
59
|
+
* spec naming no version never reaches here (`planHop` refused it), and is left as written.
|
|
59
60
|
*/
|
|
60
61
|
export const rewritePins = (text: string, to: string): string =>
|
|
61
62
|
text.replace(PIN_ENTRY, (whole, head: string, _name: string, spec: string, tail: string) =>
|
|
62
|
-
|
|
63
|
+
pinMoves(spec, to) ? `${head}${to}${tail}` : whole,
|
|
63
64
|
);
|
package/src/upgrade-plan.ts
CHANGED
|
@@ -63,7 +63,7 @@ export function releaseOf(version: string): Triple | undefined {
|
|
|
63
63
|
|
|
64
64
|
const tripleOf = (version: string): Triple => releaseOf(version) ?? [0, 0, 0];
|
|
65
65
|
|
|
66
|
-
const
|
|
66
|
+
const compareVersions = (a: string, b: string): number => {
|
|
67
67
|
const left = tripleOf(a);
|
|
68
68
|
const right = tripleOf(b);
|
|
69
69
|
return left[0] - right[0] || left[1] - right[1] || left[2] - right[2];
|
|
@@ -71,6 +71,15 @@ const compare = (a: string, b: string): number => {
|
|
|
71
71
|
|
|
72
72
|
const majorOf = (version: string): number => tripleOf(version)[0];
|
|
73
73
|
|
|
74
|
+
/**
|
|
75
|
+
* Whether a hop to `to` rewrites this pin. One at or below it moves; one above it — a workspace a
|
|
76
|
+
* mixed set already has further on — stays, since rewriting it would move it backwards.
|
|
77
|
+
*/
|
|
78
|
+
export const pinMoves = (spec: string, to: string): boolean => {
|
|
79
|
+
const version = pinVersion(spec);
|
|
80
|
+
return version !== undefined && compareVersions(version, to) <= 0;
|
|
81
|
+
};
|
|
82
|
+
|
|
74
83
|
/** The walkthrough that gets an app into `major`, built from the heading rule. */
|
|
75
84
|
export function sectionFor(major: number): UpgradeSection {
|
|
76
85
|
const from = RECORDED_FROM.get(major) ?? `${String(major - 1)}.x`;
|
|
@@ -97,7 +106,7 @@ const bareTarget = (from: string, released: readonly string[]): string | undefin
|
|
|
97
106
|
const next = nextMajorNewest(from, released);
|
|
98
107
|
if (next !== undefined) return next;
|
|
99
108
|
const newest = newestOfMajor(majorOf(from), released);
|
|
100
|
-
return newest !== undefined &&
|
|
109
|
+
return newest !== undefined && compareVersions(newest, from) > 0 ? newest : undefined;
|
|
101
110
|
};
|
|
102
111
|
|
|
103
112
|
const unreadablePin = (cause: string, file: string): UltimateError =>
|
|
@@ -133,10 +142,21 @@ function readVersions(pins: readonly UpgradePin[]): readonly string[] {
|
|
|
133
142
|
*/
|
|
134
143
|
const aligningVersion = (high: string, released: readonly string[]): string => {
|
|
135
144
|
const newest = newestOfMajor(majorOf(high), released);
|
|
136
|
-
if (newest !== undefined &&
|
|
145
|
+
if (newest !== undefined && compareVersions(newest, high) >= 0) return newest;
|
|
137
146
|
return nextMajorNewest(high, released) ?? high;
|
|
138
147
|
};
|
|
139
148
|
|
|
149
|
+
/**
|
|
150
|
+
* The one hop out of a mixed set: its lowest pins' next major, one walkthrough at a time, until that
|
|
151
|
+
* major reaches the highest pin's — where the hop that brings the set level is the next one.
|
|
152
|
+
*/
|
|
153
|
+
const lowestHop = (low: string, high: string, released: readonly string[]): string => {
|
|
154
|
+
const next = nextMajorNewest(low, released);
|
|
155
|
+
return next !== undefined && majorOf(next) < majorOf(high)
|
|
156
|
+
? next
|
|
157
|
+
: aligningVersion(high, released);
|
|
158
|
+
};
|
|
159
|
+
|
|
140
160
|
const tooWide = (cause: string, target: string | undefined): UltimateError =>
|
|
141
161
|
new UltimateError({
|
|
142
162
|
code: 'X_UPGRADE_HOP_TOO_WIDE',
|
|
@@ -159,16 +179,18 @@ function checkTarget(input: {
|
|
|
159
179
|
fix: 'bun pm view @ultimat3/cli versions --json # then: x upgrade --to <one of them> --json',
|
|
160
180
|
});
|
|
161
181
|
}
|
|
162
|
-
// A mixed set has one way out, so every refusal over it names that way:
|
|
163
|
-
// major
|
|
164
|
-
const
|
|
165
|
-
const
|
|
166
|
-
|
|
182
|
+
// A mixed set has one way out, so every refusal over it names that way: its lowest pins, one
|
|
183
|
+
// major further. Never the highest pin's version: that skipped every walkthrough between.
|
|
184
|
+
const mixed = low !== high;
|
|
185
|
+
const step = mixed ? lowestHop(low, high, released) : (nextMajorNewest(low, released) ?? low);
|
|
186
|
+
const way = mixed ? step : bareTarget(low, released);
|
|
187
|
+
if (mixed && compareVersions(to, low) <= 0) {
|
|
188
|
+
throw tooWide(`--to ${to} moves none of the lowest pins, at ${low}`, way);
|
|
189
|
+
}
|
|
190
|
+
if (!mixed && compareVersions(to, high) < 0) {
|
|
167
191
|
throw tooWide(`--to ${to} is below the ${high} this app already pins`, way);
|
|
168
192
|
}
|
|
169
|
-
|
|
170
|
-
// `--to <19.x>`, which brings the straggler level and crosses nothing the rest of the app has not.
|
|
171
|
-
const widest = Math.max(majorOf(nextMajorNewest(low, released) ?? low), majorOf(aligning));
|
|
193
|
+
const widest = majorOf(step);
|
|
172
194
|
if (majorOf(to) > widest) {
|
|
173
195
|
throw tooWide(
|
|
174
196
|
`--to ${to} skips a major: from ${low} a run reaches ${String(widest)}.x at most, so each hop's gate attributes its red to that hop`,
|
|
@@ -184,11 +206,11 @@ export function planHop(input: {
|
|
|
184
206
|
readonly to?: string;
|
|
185
207
|
}): UpgradeHop {
|
|
186
208
|
const { pins, to } = input;
|
|
187
|
-
const distinct = [...new Set(readVersions(pins))].sort(
|
|
209
|
+
const distinct = [...new Set(readVersions(pins))].sort(compareVersions);
|
|
188
210
|
const low = distinct[0] ?? '0.0.0';
|
|
189
211
|
const high = distinct.at(-1) ?? low;
|
|
190
212
|
const released = input.versions.filter((version) => releaseOf(version) !== undefined);
|
|
191
|
-
released.sort(
|
|
213
|
+
released.sort(compareVersions);
|
|
192
214
|
|
|
193
215
|
if (to === undefined && distinct.length > 1) {
|
|
194
216
|
// The stragglers, not the first few: `^19.4.0` nineteen times says nothing about the one `^19.3.1`.
|
|
@@ -196,19 +218,24 @@ export function planHop(input: {
|
|
|
196
218
|
const held = stragglers.map((pin) => `${pin.file}: ${pin.name} ${pin.spec}`);
|
|
197
219
|
throw new UltimateError({
|
|
198
220
|
code: 'X_UPGRADE_MIXED_PINS',
|
|
199
|
-
cause: `the workspaces pin ${distinct.join(', ')}, and the framework only moves in lockstep. ${String(held.length)} of ${String(pins.length)} below ${high}: ${held.slice(0, 6).join('; ')}`,
|
|
200
|
-
fix: `x upgrade --to ${quoteArg(
|
|
221
|
+
cause: `the workspaces pin ${distinct.join(', ')}, and the framework only moves in lockstep, so a run moves the lowest pins one major until the set is level. ${String(held.length)} of ${String(pins.length)} below ${high}: ${held.slice(0, 6).join('; ')}`,
|
|
222
|
+
fix: `x upgrade --to ${quoteArg(lowestHop(low, high, released))} --json`,
|
|
201
223
|
});
|
|
202
224
|
}
|
|
203
225
|
if (to !== undefined) checkTarget({ to, low, high, released });
|
|
204
226
|
|
|
205
227
|
const target = to ?? bareTarget(low, released) ?? low;
|
|
206
|
-
|
|
228
|
+
// Still below the highest pin, the set stays mixed: the next run is the lowest pins' next hop.
|
|
229
|
+
const after =
|
|
230
|
+
compareVersions(target, high) < 0
|
|
231
|
+
? lowestHop(target, high, released)
|
|
232
|
+
: (bareTarget(target, released) ?? null);
|
|
207
233
|
return {
|
|
208
234
|
from: low,
|
|
209
235
|
to: target,
|
|
210
236
|
next: after,
|
|
211
237
|
atTarget: distinct.length === 1 && target === low,
|
|
212
|
-
|
|
238
|
+
// The lowest pins are the ones that cross: theirs is the walkthrough this hop needs.
|
|
239
|
+
section: majorOf(target) > majorOf(low) ? sectionFor(majorOf(target)) : null,
|
|
213
240
|
};
|
|
214
241
|
}
|
package/src/upgrade-report.ts
CHANGED
|
@@ -7,6 +7,7 @@ import type { AgentsLinkState } from './upgrade-agents-link';
|
|
|
7
7
|
import type { BunEdit, BunManual } from './upgrade-bun-floor';
|
|
8
8
|
import type { GateFinding, HopGate, RepoScript } from './upgrade-gate';
|
|
9
9
|
import type { UpgradeHop, UpgradePin } from './upgrade-plan';
|
|
10
|
+
import { pinMoves } from './upgrade-plan';
|
|
10
11
|
|
|
11
12
|
export interface PlannedWrites {
|
|
12
13
|
/** Absolute path to final text; a manifest holding both a pin and a floor is written once. */
|
|
@@ -125,7 +126,9 @@ export const report = (input: {
|
|
|
125
126
|
section: hop.section,
|
|
126
127
|
pins: hop.atTarget
|
|
127
128
|
? []
|
|
128
|
-
: pins
|
|
129
|
+
: pins
|
|
130
|
+
.filter((pin) => pinMoves(pin.spec, hop.to))
|
|
131
|
+
.map((pin) => ({ file: pin.file, name: pin.name, from: pin.spec, to: hop.to })),
|
|
129
132
|
bun: { from: bunFrom, to: bunTo, edited: writes.edited, manual: writes.manual },
|
|
130
133
|
agentsMd: input.agentsMd ?? null,
|
|
131
134
|
guards: gate?.guards ?? null,
|
package/src/verify-checks.ts
CHANGED
|
@@ -52,6 +52,7 @@ import { unknownToolGateFindings } from './tool-gate-names';
|
|
|
52
52
|
import { measureIfStale } from './verify-budgets-build';
|
|
53
53
|
import { exerciseStep } from './verify-exercise';
|
|
54
54
|
import { floorProblemFindings, readVerifyFloor } from './verify-floor';
|
|
55
|
+
import { mcpMountFindings } from './verify-mcp-mount';
|
|
55
56
|
import { roleLoadFindings } from './verify-role-load';
|
|
56
57
|
import type { VerifyStep } from './verify-step';
|
|
57
58
|
import { fromExec, fromFindings, hostFindings } from './verify-step';
|
|
@@ -400,6 +401,8 @@ export const VERIFY_STEPS: readonly VerifyStep[] = [
|
|
|
400
401
|
...(await unregisteredJobFindings(ctx.root)),
|
|
401
402
|
...(await retainUnsweptFindings(ctx.root)),
|
|
402
403
|
...(await readJobBudgetFindings(ctx.root)),
|
|
404
|
+
// `ai.mcp.expose: true` and no mounted `POST /mcp`: what every boot only logged.
|
|
405
|
+
...(await mcpMountFindings(ctx.root)),
|
|
403
406
|
...(await unknownToolGateFindings(ctx.root)),
|
|
404
407
|
...remote.findings,
|
|
405
408
|
]
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// The `manifest` step's MCP rider: `ai.mcp.expose: true` with nothing mounted is a finding. The
|
|
2
|
+
// boot's own `appMcpMount` decides it — the same answer `x dev` and the web role log — so the gate
|
|
3
|
+
// and the boot can never disagree about whether `POST /mcp` exists. A boot only WARNED, and every
|
|
4
|
+
// tracked app shipped its catalog unmounted behind that warning.
|
|
5
|
+
|
|
6
|
+
import { isUltimateError } from '@ultimat3/core';
|
|
7
|
+
import { loadApp } from './app-load';
|
|
8
|
+
import { appMcpMount } from './app-mcp';
|
|
9
|
+
import type { AppLoader } from './app-permissions';
|
|
10
|
+
import type { Finding } from './output';
|
|
11
|
+
import { findingFrom } from './output';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Empty when the app did not load (the load findings say so, once) or `expose` is off. A refusal
|
|
15
|
+
* the mount THROWS — two files on one path (`X_MCP_PATH_DUPLICATE`), a catalog projected before the
|
|
16
|
+
* scan (`X_MCP_INCLUDE_EARLY`) — is a finding too: the boot would stop on it.
|
|
17
|
+
*/
|
|
18
|
+
export async function mcpMountFindings(
|
|
19
|
+
root: string,
|
|
20
|
+
load: AppLoader = loadApp,
|
|
21
|
+
): Promise<readonly Finding[]> {
|
|
22
|
+
if ((await load(root)).findings.length > 0) return [];
|
|
23
|
+
try {
|
|
24
|
+
const { warning } = await appMcpMount(root);
|
|
25
|
+
return warning === undefined ? [] : [findingFrom(warning)];
|
|
26
|
+
} catch (error) {
|
|
27
|
+
if (isUltimateError(error)) return [findingFrom(error)];
|
|
28
|
+
throw error;
|
|
29
|
+
}
|
|
30
|
+
}
|
package/wiki/Admin-Dashboard.md
CHANGED
|
@@ -253,6 +253,37 @@ mcp: { expose: true, description: 'Publish a draft post' },
|
|
|
253
253
|
|
|
254
254
|
The projected tool calls `action.run(...)` — the same entry point the HTTP route calls. Policy evaluation lives inside `run`, so there is nothing to keep in sync. Details: [MCP and AI](MCP-And-AI).
|
|
255
255
|
|
|
256
|
+
### An admin surface serves the app's tools
|
|
257
|
+
|
|
258
|
+
`As of 2026-11`. One way: `apps/admin/mcp.ts` exports `adminMcp(...)`, at module scope, and the
|
|
259
|
+
mount serves it beside `apps/web/mcp.ts`. `x new` writes it.
|
|
260
|
+
|
|
261
|
+
```ts
|
|
262
|
+
// apps/admin/mcp.ts
|
|
263
|
+
import { type AdminApp, type AdminMcpActor, adminMcp } from '@ultimat3/admin';
|
|
264
|
+
import type { McpRequestFacts } from '@ultimat3/mcp';
|
|
265
|
+
declare const admin: AdminApp; // `import { admin } from './app/admin/admin'` — the defineAdmin() module
|
|
266
|
+
// The web surface's resolver (`apps/web/app/auth/agent-token.ts`) and its mapping onto an admin actor,
|
|
267
|
+
// both as `x new` writes them.
|
|
268
|
+
declare function resolveAgentToken(token: string, facts?: McpRequestFacts): Promise<unknown>;
|
|
269
|
+
declare function adminAgentOf(caller: unknown): AdminMcpActor | null;
|
|
270
|
+
|
|
271
|
+
export const mcp = adminMcp({
|
|
272
|
+
app: admin,
|
|
273
|
+
path: '/admin/mcp',
|
|
274
|
+
include: 'exposed',
|
|
275
|
+
actor: async ({ token, facts }) =>
|
|
276
|
+
adminAgentOf(token === undefined ? null : await resolveAgentToken(token, facts)),
|
|
277
|
+
});
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
| Option | Rule |
|
|
281
|
+
|---|---|
|
|
282
|
+
| `path` | `defineAppMcp`'s own; default `/mcp`, which collides with the web endpoint (`X_MCP_PATH_DUPLICATE`) — an admin surface says `/admin/mcp` |
|
|
283
|
+
| `include: 'exposed'` | every action and query with `mcp: { expose: true }`, projected as on `/mcp` (opt-in, the one predicate), each under its kind's admin scope: a query `admin:read`, an action `admin:write` |
|
|
284
|
+
| `actor({ token, facts })` | `facts` is how the request arrived (address, `Origin`) — what a development token is decided on |
|
|
285
|
+
| where it is built | `apps/<app>/mcp.ts`, never under `apps/admin/app/`: the scan evaluates the former after every primitive registered; one built earlier is `X_MCP_INCLUDE_EARLY` at the mount |
|
|
286
|
+
|
|
256
287
|
### Tool scopes
|
|
257
288
|
|
|
258
289
|
`As of 2026-10`. `adminMcp()` hands the MCP registry one scope per tool (`adminToolScopes`, `packages/admin/src/mcp-scopes.ts`). Tool names are unchanged.
|
package/wiki/CLI-Reference.md
CHANGED
|
@@ -1532,7 +1532,7 @@ One major per run, so each hop's gate attributes its red to that hop. `As of 30.
|
|
|
1532
1532
|
|
|
1533
1533
|
A dry run refuses exactly what the real run would, then stops before step 4. It never commits; `git diff` is the hop. Under `--json` fd 1 is one document, fd 2 a `{"upgrade":"<stage>"}` line per stage and a heartbeat a minute.
|
|
1534
1534
|
|
|
1535
|
-
`--to`: an exact release in the next major. Over a mixed set
|
|
1535
|
+
`--to`: an exact release in the next major. Over a mixed set a run moves the LOWEST pins one major: `^19` beside `^29` is told `--to 20.x.y`, that hop rewrites only the pins at or below it (a pin above is never moved backwards) and names its walkthrough, `next` names the following hop, and the last one levels the set. A straggler jump straight to the highest major is `X_UPGRADE_HOP_TOO_WIDE`: it skipped every walkthrough between.
|
|
1536
1536
|
|
|
1537
1537
|
| `data` field | Meaning |
|
|
1538
1538
|
|---|---|
|
package/wiki/Configuration.md
CHANGED
|
@@ -148,7 +148,7 @@ ROLE: worker ROLE: worker
|
|
|
148
148
|
WORKER_QUEUES: banks WORKER_QUEUES: banks-long
|
|
149
149
|
```
|
|
150
150
|
|
|
151
|
-
Each worker then claims its list and nothing else. Every entry must be a queue the app knows — in `jobs.queues` or a registered job's — or the boot refuses with `X_WORKER_QUEUE_UNKNOWN` (`As of 29.0.0`).
|
|
151
|
+
Each worker then claims its list and nothing else. Every entry must be a queue the app knows — in `jobs.queues` or a registered job's — or the boot refuses with `X_WORKER_QUEUE_UNKNOWN` (`As of 29.0.0`). After it starts it logs `jobs.worker.queue-unserved` (a warning, not a refusal) naming every queue a registered job is enqueued on that NO live worker in the registry serves (`As of 2026-11-07`), so a queue no Deployment serves is visible in the first log line rather than as a backlog — and a split fleet whose other Deployments serve the rest logs nothing. A driver with no worker registry says it at `info`. `x jobs list --json` shows each queue's workers.
|
|
152
152
|
|
|
153
153
|
## `realtime`
|
|
154
154
|
|
package/wiki/Deployment.md
CHANGED
|
@@ -248,7 +248,7 @@ services:
|
|
|
248
248
|
# WORKER_QUEUES is the EXACT set a worker claims (25.0.0): `integrations` runs on its own
|
|
249
249
|
# service, so the other worker names every remaining queue itself. Unset, a worker claims
|
|
250
250
|
# jobs.queues plus every queue a registered job is enqueued on; set, `jobs.worker.queue-unserved`
|
|
251
|
-
#
|
|
251
|
+
# names the registered queues it leaves that NO live worker serves — none, once `integrations` is up.
|
|
252
252
|
worker: { <<: *app, environment: { ROLE: worker, WORKER_QUEUES: 'myapp-default,default' },
|
|
253
253
|
deploy: { replicas: 4 } }
|
|
254
254
|
worker-integrations: { <<: *app, environment: { ROLE: worker, WORKER_QUEUES: integrations },
|
|
@@ -148,6 +148,8 @@ run: async ({ input, ctx, step }) => {
|
|
|
148
148
|
|
|
149
149
|
**A cancelled body writes nothing** (`As of 2026-11-02`). Once `ctx.signal` is aborted with a coded reason (`X_ABORTED`, `X_DRAINING`, `X_JOB_TIMEOUT`, `X_JOB_SLOT_LOST`, `X_JOB_LEASE_LOST`), a transaction, an entity write and an `enqueue` rethrow that reason. The step record stays fenced as before and a drained step still lands. Wrap work that must land in `runToCompletion(fn)`.
|
|
150
150
|
|
|
151
|
+
**A finished attempt is not a cancelled one** (`As of 2026-11-07`). A body that completed, and whose `ack` landed, leaves `ctx.signal` un-aborted: no other attempt owns the run, so a timer it armed — a log sink's flush, an `end()` timeout, a deferred `enqueue` — still writes. A step it never awaited is refused (`X_ABORTED`, "had already ended"). A failed or suspended attempt, or a completed one whose `ack` did not land, aborts with `X_ABORTED` as before. Work meant to outlive the attempt whatever its end has ONE way: `runToCompletion(() => work)` from `@ultimat3/core`.
|
|
152
|
+
|
|
151
153
|
**SIGTERM fires it too** (`As of 2026-09-07`). The moment the process starts draining, the worker aborts `ctx.signal` on every job it holds with `X_DRAINING` — naming the worker and the signal — and then gives the body the drain's budget to unwind in. A body that stops is **interrupted**, not failed: the job goes straight back to the ready bucket with the attempt uncounted, so `attempts: 1` survives a rollout and the worker replacing this one claims it at once. The verdict is read off the signal, not off whatever the body threw, so an app's own error for a child the shutdown killed is an interruption too. A body that ignores the signal is waited on to the deadline and abandoned there, as before; a manual `worker.stop()` aborts nothing and waits for its work — until a SIGTERM lands on it, at which point the teardown already waiting adopts the shutdown's deadline and every held job hears the abort.
|
|
152
154
|
|
|
153
155
|
| Past the cancel | What happens |
|
package/wiki/MCP-And-AI.md
CHANGED
|
@@ -257,16 +257,19 @@ developer's.
|
|
|
257
257
|
`As of 2026-09-05`. `defineAppMcp({ …, resolveToken })` builds `mcp.route`, and `app.config.ts`
|
|
258
258
|
declares `ai: { mcp: { expose: true } }` **by default** (the path is `defineAppMcp`'s own, `/mcp` by default) — and until this date nothing
|
|
259
259
|
between the two served it: `POST /mcp` was `X_ROUTE_NOT_FOUND` under `x dev` and in every
|
|
260
|
-
container. The contract is one file:
|
|
260
|
+
container. The contract is one file per app directory:
|
|
261
261
|
|
|
262
262
|
| Piece | Where | Rule |
|
|
263
263
|
|---|---|---|
|
|
264
264
|
| the declaration | `apps/<app>/mcp.ts` | `export const mcp = defineAppMcp({ include: 'exposed', resolveToken })` |
|
|
265
|
-
| the switch
|
|
265
|
+
| the switch | `app.config.ts` → `ai.mcp` | `expose` (default `true`). The path is each endpoint's own `defineAppMcp({ path })` (default `/mcp`) |
|
|
266
|
+
| several app directories | every `apps/<app>/mcp.ts` mounts (`As of 2026-11`) | `apps/web/mcp.ts` first — its first endpoint is the default — then the rest sorted. An admin surface's `apps/admin/mcp.ts` stands beside the web one; until 2026-11 the first sorted file mounted ALONE, so it silently took `/mcp` away. Two files claiming one route: `X_MCP_PATH_DUPLICATE` naming both |
|
|
267
|
+
| when it is built | at the top level of `apps/<app>/mcp.ts` | the boot scan evaluates that file after every `apps/*/{site,app,api,shared}` module has registered, so `include: 'exposed'` sees every exposed primitive. An endpoint projected earlier — in a module under `apps/<app>/app/` that `mcp.ts` re-exports — is `X_MCP_INCLUDE_EARLY` at the mount, naming the tools it lacks, never a short catalog. Never import the API index to fill the registry (`X_BOUNDARY_APP_TO_API`) |
|
|
266
268
|
| the mount | `x dev` **and** `runRole`, through one call | `POST <path>` → `mcp.route.handle(request)`; the boot log says `app mcp mounted`, `x dev`'s summary prints `mcp POST /mcp` |
|
|
267
269
|
| auth | the route's own | `auth: 'public'`, `enforcedBy: 'handler'` on the http route — the bearer token is read by `resolveToken`, never pre-judged by the pipeline |
|
|
268
270
|
| exposed and unmountable | `X_MCP_APP_UNMOUNTED`, logged once | no file exports `mcp`, or it was built without `resolveToken` (no `route`); the fix names the file to write |
|
|
269
271
|
| a new app | `x new` writes it | `apps/web/mcp.ts` over the `@<app>/mcp` catalog, `resolveToken` from `apps/web/app/auth/agent-token.ts`: an API key (`issueApiKey`, `x_api_keys`) is an agent in every environment; `dev-agent.<role>` is one under `x dev` alone, and only from a loopback address with no `Origin` (a page rebound to 127.0.0.1 sends one). No token, or one it does not know: 401 |
|
|
272
|
+
| the admin surface | `x new` writes it too | `apps/admin/mcp.ts`: `adminMcp({ app: admin, path: '/admin/mcp', include: 'exposed', actor })` — the admin's own tools and the app's exposed ones, the actor from the same `resolveAgentToken` (handed the request's `facts`). A key with scopes is held to its `admin:*` ones, a key with none is as strong as its actor → [Admin dashboard](Admin-Dashboard#an-admin-surface-serves-the-apps-tools) |
|
|
270
273
|
|
|
271
274
|
**A token from an external issuer** (`As of 2026-11-05`): an app whose agents present an IdP's JWT
|
|
272
275
|
rather than an API key hands `defineAppMcp` `jwtBearerResolver` from `@ultimat3/auth` — the twin of
|
package/wiki/Testing.md
CHANGED
|
@@ -594,6 +594,7 @@ unitTest('a row deleted before the run is skipped, not failed', async ({ runJobs
|
|
|
594
594
|
| On the trace or the call | Is |
|
|
595
595
|
|---|---|
|
|
596
596
|
| `trace.executions[n].result` | what the body returned. Absent on a run that failed, suspended or was interrupted |
|
|
597
|
+
| `trace.executions[n].code` | the stable `X_*` code a failed run's error carried, beside its `error` message. Absent on an uncoded throw and on a run that did not fail |
|
|
597
598
|
| `trace.steps[name].executions` | times the step body ran — a replay from storage does not count. Cumulative for the test |
|
|
598
599
|
| `runJobs(job, input, { actor })` · `drain({ actor })` | the identity the **worker** runs as (`WorkerOptions.context()` in a real one). Its org is still the job's declared `tenant`; `tenant: 'none'` strips it. Absent is the anonymous actor |
|
|
599
600
|
| `runJobs(job, input, { tenantId })` · `enqueue(…, { tenantId })` | the **enqueuer's** tenant on the row: the limiter's bucket and the dedupe namespace, as `handle.as(actor, input)` files it. One key in two tenants is two jobs |
|
|
@@ -673,6 +674,12 @@ describeIslandState(counterStates, 'idle', island, (mounted) => {
|
|
|
673
674
|
The props are the manifest's, so the picture `x shot --island` takes and the test are of one
|
|
674
675
|
component. `mountIsland` stays the call for props no state declares. `As of 2026-10`.
|
|
675
676
|
|
|
677
|
+
A realtime island's page outlives its mount: the socket, the record store and the outbox sit on
|
|
678
|
+
`globalThis`, shared by every bundle. Between files (or cases that need a fresh page) call
|
|
679
|
+
`resetPageRealtime()` from `@ultimat3/realtime` in `afterAll` — it closes the socket the CHUNK's copy
|
|
680
|
+
of realtime built and drops every page global. Never `Reflect.deleteProperty(globalThis,
|
|
681
|
+
Symbol.for('ultimate.realtime'))`: that leaves the socket open. `As of 2026-11`.
|
|
682
|
+
|
|
676
683
|
## Testing an authenticator
|
|
677
684
|
|
|
678
685
|
```ts
|
|
@@ -846,7 +853,7 @@ A column whose value comes from another factory, built with the **same strategy*
|
|
|
846
853
|
|
|
847
854
|
`ids.uuid()` and `ids.number()` come from the seeded generators; `index` starts at 1 for the first row.
|
|
848
855
|
|
|
849
|
-
`factoriesFor(registry, seed?)` derives a factory per entity, each value read off the column's **declaration**, never its name (`As of 2026-11`, 30.0.0 entry 89): the declared default first, else an enumerated value, else what the kind offers (uuid, text, integer, boolean, timestamp, money as `{ minor, currency }`, …), and `null` only where the column is nullable. Every candidate goes through the column's own `$parse`, so a built row is one the table stores. A registry entry whose `$columns` are not `entity()` columns, or a column that refuses every generated value (a `json(
|
|
856
|
+
`factoriesFor(registry, seed?)` derives a factory per entity, each value read off the column's **declaration**, never its name (`As of 2026-11`, 30.0.0 entry 89): the declared default first, else an enumerated value, else what the kind offers (uuid, text, integer, boolean, timestamp, money as `{ minor, currency }`, …), and `null` only where the column is nullable. A text column held to a `matches(/…/)` invariant is also offered strings built from that pattern, and those come first; a `json(schema)` column is offered values its schema accepts (`As of 2026-11-07`). Every candidate goes through the column's own `$parse`, so a built row is one the table stores. A registry entry whose `$columns` are not `entity()` columns, or a column that refuses every generated value (a `json()` whose schema carries a `pattern` its sampler cannot meet), throws `X_SEED_GRAPH_UNSATISFIED` at `build()`, naming the `defineFactory` to write. Source: `packages/testing/src/factory-registry.ts`, `seed-graph-values.ts`.
|
|
850
857
|
|
|
851
858
|
### Persisting
|
|
852
859
|
|
|
@@ -889,8 +896,9 @@ await insertSeedGraph(graph, writer);
|
|
|
889
896
|
|---|---|
|
|
890
897
|
| order | parents before children, over the entities' own `$references()` |
|
|
891
898
|
| tenancy | each child points at a parent of its **own** tenant; the entity a tenant column references holds one row per tenant, keyed by the tenant. A table with no tenant column whose references reach a tenant-owned one, and no `shared` reason, is seeded per tenant too, under its own tenant's parent — `row.tenant` is that parent's tenant |
|
|
892
|
-
| a cycle | broken at a nullable key: inserted `null`, set by an entry of `graph.updates` once its target exists. A cycle with no nullable key is `X_SEED_GRAPH_UNSATISFIED` |
|
|
899
|
+
| a cycle | broken at a nullable key: inserted `null`, set by an entry of `graph.updates` once its target exists. The break prefers a table that takes updates — never an `appendOnly` one, whose key stays `null` when it is the only break. In `x exercise`, a deferred update the database refuses (a trigger no declaration carries) leaves the key `null`; an environment failure still fails. A cycle with no nullable key is `X_SEED_GRAPH_UNSATISFIED` |
|
|
893
900
|
| values | read off the declaration, as `factoriesFor` reads them, and kept only if `$parse` accepts them |
|
|
901
|
+
| patterns and schemas | a `matches(/…/)` pattern adds strings drawn from the pattern itself: literals, classes, `.`, groups, `\|`, quantifiers, anchors. Lookaround, backreferences, `\b` and `\p{…}` add nothing, so the column's other candidates stand. A `json(schema)` column draws from its schema, as the contract step's `arbitrary` does. Both are seeded by the row, so the same seed gives the same bytes |
|
|
894
902
|
| rules | every invariant, the primary key, unique columns and unique indexes are kept; a partial unique is treated as total. A unique column that runs out of values ends its table early — but a table-wide unique that runs out before every tenant holds a row is `X_SEED_GRAPH_UNSATISFIED` (`… runs out of values before tenant 2 of 2 holds a row`): that tenant's children would have no parent |
|
|
895
903
|
| an unseeded target | a key — the tenant column included — whose target entity is not in the set is `X_SEED_GRAPH_UNSATISFIED` (`… which is not in the set seeded — pass it too`) |
|
|
896
904
|
| soft delete | a soft-deleting table's `deletedAt` is `null` unless a factory builds it: seeded rows are live |
|
|
@@ -957,7 +965,8 @@ A legal input that crashes the handler is red in `contract`, the day the action
|
|
|
957
965
|
| Runs | 16 (`EXERCISE_RUNS` from `@ultimat3/core`), `runs` from 1 to 64. Runs 0-3 sit on the IR's bounds (`minLength`, `maxLength`, `minimum`, `maximum`, `minItems`). The rest draw multibyte text, every enum and union member, optional keys on and off, `null` where nullable. A format value (`t.uuid`, `t.email`, …) is never varied |
|
|
958
966
|
| Seed | `fnv1a(<action name>)`, never time, so a red run replays: `bun test <file> -t '<name>: handler survives generated input'` |
|
|
959
967
|
| Rolled back | each run inside a memory transaction, always, and in it `withTransaction` when SQL can hold the writes: a SAVEPOINT in a test's open transaction, a root transaction once `setDbClient` installed a client. Both always roll back. The handler's SQL writes, its memory-store writes (`storeMode` `memory`, `tx` passed or not, since `memoryTransactor().run` is ambient), the outbox stage and the post-commit bust drop with it (`packages/action/src/tx-scope.ts` `inRolledBackScope`) |
|
|
960
|
-
| **Not rolled back** | a write on a second client or `outsideTransaction`. A job enqueued with `outbox: false`. Storage puts. Mail and outbound fetch: the test preload seals both, so a real one is only sent where a test opted out.
|
|
968
|
+
| **Not rolled back** | a write on a second client or `outsideTransaction`. A job enqueued with `outbox: false`. Storage puts. Mail and outbound fetch: the test preload seals both, so a real one is only sent where a test opted out. An audit record a non-database sink accepted |
|
|
969
|
+
| Rate-limit counters | spent in a scratch store of the exercise's own (`withScratchRateLimitStore`, `@ultimat3/http`), answering for the installed store's scope: the same file's next call has its whole declared limit |
|
|
961
970
|
| Where it runs | `development` and `test` only. `staging` or `production` (`ULTIMATE_ENV`, else `NODE_ENV`) is `X_CONTRACT_EXERCISE_ENVIRONMENT` before the first run: generated input never reaches a deploy. `bun test` sets `NODE_ENV=test` |
|
|
962
971
|
| Pass | the output parsed, or a coded refusal under 500 |
|
|
963
972
|
| Defect, `X_CONTRACT_DEFECT` | an uncoded throw, `X_OUTPUT_INVALID`, or a coded 5xx (an undeclared `errors:` code is one). `meta.run`, `meta.input`, `meta.failure` |
|
|
@@ -984,11 +993,13 @@ shape, same unreached code. `As of 2026-11`.
|
|
|
984
993
|
| Rows | one seed graph for the pass, a fresh pair of tenants per primitive in it — the first the actor's (A), the second the tenant proof's intruder (B); every table seeded off its declaration, parents first, same-tenant keys (`x db seed --generate`'s graph). A view and a `readOnly: '<why>'` table get no row; a NOT NULL key into a `readOnly` one is `X_SEED_GRAPH_UNSATISFIED` naming it ([Read-only tables](Entities-And-Migrations#read-only-tables)) |
|
|
985
994
|
| Actor | the first of `x policy explain`'s actors the policy allows, in the primitive's tenant. None allowed is `refused`, by design |
|
|
986
995
|
| Input | the override, then an id field whose stem names a seeded entity (`postId` → `posts`), then the schema's minimal draw. A wrong guess shows as `unreached` |
|
|
987
|
-
| A job's org | the field(s) its `tenant(input)` reads — any name, nested,
|
|
996
|
+
| A job's org | the field(s) its `tenant(input)` reads — any name, nested, left out of the draw, or under a parent the draw left out (a scoped backfill's `payload.input.orgId`) — are set to the tenant it is enqueued in, found by reading them: A's for the control, B's for the tenant proof (B's own job, carrying A's ids). A `tenant` no input field moves (one fixed org) is `unreached`, `X_JOB_TENANT_MISMATCH`, its `fix:` the declaration: `tenant: (input) => input.orgId` |
|
|
997
|
+
| A job's failure | judged by the action rule: a coded refusal under 500 (an app's 409) is `unreached` under its own code, never a defect; an uncoded throw or a coded 5xx is `X_CONTRACT_DEFECT` |
|
|
988
998
|
| Async input | an override's `input` may be `async`: it is awaited inside the call's request, as the call's actor, so it can mint what the action checks (a one-time token, a passkey assertion). `x drill`'s load skips an `async` input and says why |
|
|
989
999
|
| Room | network sealed, mail captured, jobs in memory, clock frozen — from before the app loads and the scratch database boots, as `bun test`'s preload does, so the embedded Postgres never sees its clock jump. A call out is refused and counted, so the primitive is `unreached` |
|
|
990
1000
|
| Cost | rows are primitives × tables; statements are not: the graph is written one `insertAll` per table, each tenant-proof snapshot is one statement, and a primitive reads only its own pair's rows of the graph |
|
|
991
1001
|
| Overrides | `apps/web/exercise.ts`: `export const exercise = defineExercise({ publishPost: { actor, input }, sendInvoice: { off: '<why>' } })`. Each value is `.contract({ exercise })`'s option. An unknown name is `X_DECLARATION_UNKNOWN`; a blank `off` is `X_CONTRACT_EXERCISE_UNDECLARED` |
|
|
1002
|
+
| A handler that calls a vendor | `defineExercise({ … }, { setup: () => { mockFetch('https://api.vendor.test/*', handler); } })` — or `setup: () => cassette(import.meta.dir, 'exercise')`. Runs once in the exercise process, after the seal and before the first row; what it returns is disposed after the pass. A call a setup route answers is reached, not refused. `allowHost` in it is `X_EXERCISE_SETUP_UNSEALED` |
|
|
992
1003
|
| Rows a generated value cannot satisfy | a cross-column `c.satisfies(…)` reads `X_SEED_GRAPH_UNSATISFIED`: `defineExercise({ … }, { factories: { bosses: defineFactory(bosses, { defaults: (i) => ({ … }) }) } })`, keyed by entity name, the graph's keys kept. A column that follows from a key or reference the graph picked: `derive: (row) => ({ scopeKey: … })` beside `build`/`reset`, reading `row.ownerOrgId`. A partial unique whose columns the factory builds is the database's to judge. An unknown name is `X_DECLARATION_UNKNOWN` |
|
|
993
1004
|
| Report | `.x/exercise.json` and `--json`: `{ seed, counts, records[] }`, each record `{ kind, name, outcome, code?, reason?, statements, effects, ms, override?, fix?, tenant?, crash? }` — `fix` where no override reaches it. The fields before `tenant` are A's call. The human table's `tenant` and `crash` columns are the verdicts, `-` where none ran |
|
|
994
1005
|
| Gate | the `exercise` step, after `job`, runs `x exercise --json` as a child and is red on its findings: `X_CONTRACT_EXERCISE_UNREACHED`, `X_TENANT_LEAK`, `X_TENANT_WRITE_CROSSED`, `X_JOB_CRASH_DIVERGED`, and — on a whole pass, not `x exercise <name>` — `X_ENTITY_TENANT_UNDECLARED`. `unreached` is never sampled or skipped: it is a finding whose `fix:` is the entry to paste |
|
package/wiki/Upgrading.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Upgrading
|
|
2
2
|
|
|
3
|
-
**`As of 2026-08`. Semver applies from here.** A breaking change to a documented API needs a major. Every `@ultimat3/*` package moves in lockstep — never mix versions.
|
|
3
|
+
**`As of 2026-08`. Semver applies from here.** A breaking change to a documented API needs a major. Every `@ultimat3/*` package moves in lockstep — never mix versions. `x new` declares them at `^<version>`, so `bun update` takes a same-major fix before the first hop; `x upgrade` rewrites every pin it moves exact (`20.1.0`, no caret), on purpose — a caret is how `^19.0.0` beside `^19.4.0` came to be mixed, and a range is a silent upgrade. `bun.lock` pins the exact build either way.
|
|
4
4
|
|
|
5
5
|
> **From v30, one major per 90 days.** The window, what semver covers, the support window and the security exception: [Stability](Stability). A removal now ships its `@deprecated` alias for at least one minor before a major takes it, so `x upgrade` across one major names no edit a minor did not already warn about. The 29 sections below stay as they are.
|
|
6
6
|
|
|
@@ -299,6 +299,7 @@ Only an app that relied on the old behaviour has anything to do:
|
|
|
299
299
|
| What moved | Do |
|
|
300
300
|
|---|---|
|
|
301
301
|
| with `DATABASE_REPLICA_URL` set, a request that wrote sets `__Host-x-pos` and `x-ultimate-position` | nothing. A proxy with a cookie allowlist passes `__Host-x-pos`; stripped, the next read may land on a standby that has not replayed the write |
|
|
302
|
+
| a job attempt that completed and was acked leaves `ctx.signal` un-aborted (`As of 2026-11-07`); before, its timers' writes and enqueues were refused `X_ABORTED`, "was cancelled" | nothing. A body that read an aborted signal as "the run ended" reads the settle instead (`onSettled`); work that must outlive a FAILED attempt too runs in `runToCompletion(() => work)` from `@ultimat3/core` |
|
|
302
303
|
| `onSettled`'s `ctx.signal` is live | a hook that treated an aborted signal as "the run ended" reads `settled.outcome` instead. It is aborted at `onSettledTimeout`, 30 s across every try by default: raise it on a hook that takes longer |
|
|
303
304
|
| `publishEvent` inside a transaction commits and rolls back with it | nothing, unless a rolled-back event must still resume a run: publish it in `outsideTransaction(fn)` |
|
|
304
305
|
| the `drift` step's `rollingSince` rail is off until set | optional: set `rollingSince` in `x.verify.json` to your newest applied migration id. `x doctor` reminds you |
|
|
@@ -3566,7 +3567,7 @@ bunx @ultimat3/cli@latest upgrade --json # an app below 30.0.0, whose own CLI
|
|
|
3566
3567
|
|
|
3567
3568
|
A repository with pins and no `app.config.ts` hops too: no `x verify` can run, so it runs the `typecheck` and `test` scripts it declares, and `data.proof` (`verify`, `scripts`, `none`) says what `done` rests on.
|
|
3568
3569
|
|
|
3569
|
-
Refusals name their own fix: `X_UPGRADE_MIXED_PINS` (`x upgrade --to <the
|
|
3570
|
+
Refusals name their own fix: `X_UPGRADE_MIXED_PINS` (`x upgrade --to <the lowest pins' next major> --json`), `X_UPGRADE_HOP_TOO_WIDE`, `X_UPGRADE_PIN_UNREADABLE`, `X_UPGRADE_REGISTRY_UNREACHABLE`, `X_UPGRADE_VERSION_UNPUBLISHED`, `X_UPGRADE_INSTALL_FAILED`. `data.bun.manual[]` lists what only you can move: a digest-pinned image, an expression. `data.guards.differs[]` offers `x g guard <name> --force`, and your guard is never touched. A hop below 30.0.0 reports `X_BUDGET_UNMEASURED` until `x build --target static --json` has run. Field by field: [CLI reference](CLI-Reference#x-upgrade).
|
|
3570
3571
|
|
|
3571
3572
|
## Breaking-change classes and how each is detected
|
|
3572
3573
|
|