@ultimat3/cli 30.3.0 → 30.5.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-findings.ts +27 -11
- 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 +18 -6
- package/wiki/Upgrading.md +3 -2
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
|