@ultimat3/cli 30.2.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/exercise-database.ts +15 -1
- package/src/exercise-findings.ts +3 -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/Entities-And-Migrations.md +16 -0
- package/wiki/Jobs-And-Workflows.md +2 -0
- package/wiki/MCP-And-AI.md +5 -2
- package/wiki/Testing.md +19 -6
- 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/exercise-database.ts
CHANGED
|
@@ -7,7 +7,12 @@ import type { Environment } from '@ultimat3/core';
|
|
|
7
7
|
import { ENVIRONMENT_KEY, resolveEnvironment, UltimateError } from '@ultimat3/core';
|
|
8
8
|
import type { DbClient } from '@ultimat3/db';
|
|
9
9
|
import { migrate, raw, setDbClient } from '@ultimat3/db';
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
captureDeterminism,
|
|
12
|
+
installDeterminism,
|
|
13
|
+
restoreCapturedDeterminism,
|
|
14
|
+
testSealKeyEnv,
|
|
15
|
+
} from '@ultimat3/testing';
|
|
11
16
|
import { chooseReplayEngine, withScratchDatabase } from './db-replay-engine';
|
|
12
17
|
import { applyFrameworkSchema } from './framework-schema';
|
|
13
18
|
import { readMigrations } from './migrations';
|
|
@@ -50,6 +55,12 @@ const PINNED = [ENVIRONMENT_KEY, 'NODE_ENV'] as const;
|
|
|
50
55
|
* module reads it, and the throwaway seal key `bun test` gets when the app supplied none (CI names
|
|
51
56
|
* no `ULTIMATE_SECRETS_KEY`, and every row sealed here dies with the scratch database). Every
|
|
52
57
|
* variable is put back as found: a command that exits must not leave the next one a test process.
|
|
58
|
+
*
|
|
59
|
+
* The frozen clock too, and FIRST — as `bun test`'s preload freezes it before any module loads. The
|
|
60
|
+
* scratch database is PGlite, whose Postgres arms its timers off `Date.now()`: booted on the real
|
|
61
|
+
* clock and frozen under later (the pass's room), its 10 s idle-stats timer was re-armed at
|
|
62
|
+
* real-now minus frozen-now, ~2^34 ms, clamped to 1 ms — a `TimeoutOverflowWarning` busy loop for
|
|
63
|
+
* the whole pass. One clock for the engine's whole life: the deadline and the delay agree.
|
|
53
64
|
*/
|
|
54
65
|
export async function withExerciseProcess<T>(
|
|
55
66
|
root: string,
|
|
@@ -62,10 +73,13 @@ export async function withExerciseProcess<T>(
|
|
|
62
73
|
);
|
|
63
74
|
const added = { ...pinned, ...testSealKeyEnv({ root, env: { ...env, ...pinned } }) };
|
|
64
75
|
const previous = Object.keys(added).map((key) => [key, process.env[key]] as const);
|
|
76
|
+
const clock = captureDeterminism();
|
|
77
|
+
if (!clock.installed) installDeterminism();
|
|
65
78
|
Object.assign(process.env, added);
|
|
66
79
|
try {
|
|
67
80
|
return await work();
|
|
68
81
|
} finally {
|
|
82
|
+
restoreCapturedDeterminism(clock);
|
|
69
83
|
for (const [key, value] of previous) {
|
|
70
84
|
if (value === undefined) delete process.env[key];
|
|
71
85
|
else process.env[key] = value;
|
package/src/exercise-findings.ts
CHANGED
|
@@ -57,7 +57,9 @@ const unreachedFinding = (record: ExerciseRecord): Finding => {
|
|
|
57
57
|
return {
|
|
58
58
|
code: 'X_CONTRACT_EXERCISE_UNREACHED',
|
|
59
59
|
cause: `${record.kind} ${renderCauseValue(record.name)} never reached its handler as an allowed actor in the exercise pass${stopped}${why}`,
|
|
60
|
-
fix:
|
|
60
|
+
fix:
|
|
61
|
+
record.fix ??
|
|
62
|
+
`defineExercise({ ${override} }) # paste the entry into ${EXERCISE_FILE}, or { off: '<why>' }; then: x exercise ${name} --json`,
|
|
61
63
|
docs: ERROR_DOCS_URL,
|
|
62
64
|
at: EXERCISE_FILE,
|
|
63
65
|
};
|
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;
|