@ultimat3/cli 1.2.0 → 2.0.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 +724 -0
- package/README.md +41 -9
- package/package.json +25 -23
- package/src/api-routes.ts +16 -0
- package/src/app-auth.ts +32 -0
- package/src/app-entities.ts +18 -0
- package/src/app-env.ts +103 -0
- package/src/app-load.ts +20 -3
- package/src/bin.ts +4 -3
- package/src/budgets.ts +114 -9
- package/src/cmd-build.ts +69 -21
- package/src/cmd-db-branch.ts +215 -0
- package/src/cmd-db.ts +332 -155
- package/src/cmd-deploy.ts +59 -6
- package/src/cmd-dev.ts +83 -16
- package/src/cmd-docs.ts +167 -0
- package/src/cmd-doctor.ts +64 -9
- package/src/cmd-env.ts +95 -0
- package/src/cmd-errors.ts +33 -13
- package/src/cmd-fix.ts +5 -1
- package/src/cmd-generate.ts +146 -111
- package/src/cmd-help.ts +16 -5
- package/src/cmd-i18n.ts +2 -0
- package/src/cmd-jobs.ts +47 -33
- package/src/cmd-mcp.ts +11 -2
- package/src/cmd-new.ts +13 -7
- package/src/cmd-planned.ts +55 -10
- package/src/cmd-policy.ts +1 -0
- package/src/cmd-registries.ts +3 -0
- package/src/cmd-secrets.ts +368 -0
- package/src/cmd-tasks.ts +1 -0
- package/src/cmd-test.ts +17 -23
- package/src/cmd-verify.ts +177 -23
- package/src/db-backfill.ts +401 -0
- package/src/db-branch.ts +251 -0
- package/src/db-destructive.ts +29 -0
- package/src/db-finding.ts +28 -0
- package/src/db-generate.ts +112 -0
- package/src/db-snapshot.ts +24 -0
- package/src/dev-assets.ts +86 -20
- package/src/dev-cache.ts +122 -0
- package/src/dev-dashboard.ts +19 -4
- package/src/dev-hooks.ts +27 -2
- package/src/dev-n-plus-one.ts +191 -0
- package/src/dev-queue.ts +105 -19
- package/src/dev-render.ts +158 -26
- package/src/dev-roles-fixture.ts +67 -0
- package/src/dev-roles.ts +165 -78
- package/src/dev-runtime.ts +117 -40
- package/src/dev-services.ts +15 -0
- package/src/dev-storage.ts +245 -0
- package/src/dev-sync.ts +107 -0
- package/src/dev-traces.ts +11 -3
- package/src/dispatch.ts +4 -2
- package/src/document-styles.ts +54 -0
- package/src/drift.ts +37 -9
- package/src/error-catalog.ts +7 -18
- package/src/error-codes.ts +186 -0
- package/src/error-contract.ts +29 -7
- package/src/error-fixes.ts +114 -0
- package/src/errors.ts +201 -138
- package/src/fix-command.ts +268 -0
- package/src/flag-number.ts +56 -0
- package/src/framework-scope.ts +49 -0
- package/src/generate-kinds.ts +97 -0
- package/src/guards.ts +186 -0
- package/src/index.ts +84 -14
- package/src/island-bundle.ts +166 -0
- package/src/island-routes.ts +50 -0
- package/src/jobs-driver.ts +33 -0
- package/src/jobs-json.ts +24 -0
- package/src/jobs-report.ts +17 -4
- package/src/mcp-db-target.ts +52 -27
- package/src/mcp-errors.ts +120 -19
- package/src/mcp-host.ts +44 -25
- package/src/messages.ts +81 -2
- package/src/metrics-endpoint.ts +4 -3
- package/src/migrations.ts +37 -4
- package/src/otlp-export.ts +64 -0
- package/src/output.ts +46 -16
- package/src/parse.ts +41 -3
- package/src/policy-facts.ts +38 -6
- package/src/policy-fixture.ts +14 -7
- package/src/prerender.ts +111 -2
- package/src/registry.ts +21 -3
- package/src/runtime-overrides.ts +66 -0
- package/src/safe-url-label.ts +24 -0
- package/src/scaffold-fixture.ts +10 -0
- package/src/scaffold-typecheck.ts +16 -38
- package/src/serve.ts +170 -10
- package/src/source-files.ts +4 -0
- package/src/statement-loop.ts +74 -0
- package/src/style-csp.ts +18 -0
- package/src/sync-authenticator.ts +59 -0
- package/src/templates/action.ts +15 -30
- package/src/templates/admin-page.ts +103 -0
- package/src/templates/admin.ts +11 -7
- package/src/templates/backfill.ts +212 -0
- package/src/templates/entity.ts +72 -31
- package/src/templates/guard.ts +143 -0
- package/src/templates/index.ts +12 -1
- package/src/templates/island.ts +67 -0
- package/src/templates/job.ts +53 -13
- package/src/templates/naming.ts +17 -1
- package/src/templates/policy.ts +35 -28
- package/src/templates/query.ts +24 -5
- package/src/templates/resource.ts +19 -11
- package/src/templates/route.ts +90 -15
- package/src/templates/scaffold-app.ts +142 -45
- package/src/templates/scaffold-claude-agents.ts +149 -0
- package/src/templates/scaffold-claude-commands.ts +221 -0
- package/src/templates/scaffold-claude.ts +134 -0
- package/src/templates/scaffold-container.ts +46 -2
- package/src/templates/scaffold-db-package.ts +91 -0
- package/src/templates/scaffold-docs.ts +24 -5
- package/src/templates/scaffold-domain-package.ts +90 -0
- package/src/templates/scaffold-env.ts +87 -0
- package/src/templates/scaffold-i18n.ts +4 -1
- package/src/templates/scaffold-mcp-package.ts +49 -0
- package/src/templates/scaffold-package-shape.ts +25 -4
- package/src/templates/scaffold-repo.ts +116 -257
- package/src/templates/scaffold-roles.ts +68 -0
- package/src/templates/scaffold-ui-package.ts +56 -0
- package/src/templates/slice-foundation.ts +88 -0
- package/src/templates/wrap.ts +95 -0
- package/src/test-counts.ts +35 -0
- package/src/test-select.ts +30 -15
- package/src/test-shards.ts +21 -3
- package/src/test-workers.ts +47 -0
- package/src/ts-scan.ts +271 -13
- package/src/tsconfig-references.ts +78 -0
- package/src/verify-floor.ts +133 -0
- package/src/verify-step.ts +19 -0
- package/src/verify-test-run.ts +72 -0
- package/src/verify-tests.ts +160 -71
- package/src/version-loader.ts +20 -3
- package/src/workspace-checks.ts +87 -16
- package/src/write-line.ts +34 -0
package/src/mcp-errors.ts
CHANGED
|
@@ -4,8 +4,9 @@
|
|
|
4
4
|
|
|
5
5
|
import { describeErrorCode, hasErrorCode, listErrorCodes } from '@ultimat3/core';
|
|
6
6
|
import type { ErrorExplanation } from '@ultimat3/mcp';
|
|
7
|
-
import type { CliErrorCode } from './
|
|
8
|
-
import { CLI_ERROR_CODES, docsFor } from './
|
|
7
|
+
import type { CliErrorCode } from './error-codes';
|
|
8
|
+
import { CLI_ERROR_CODES, docsFor } from './error-codes';
|
|
9
|
+
import { codeFixes, codeFixScan } from './error-fixes';
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* One runnable command per CLI code. Typed over `CliErrorCode`, so a new code fails the build.
|
|
@@ -18,17 +19,25 @@ import { CLI_ERROR_CODES, docsFor } from './errors';
|
|
|
18
19
|
*/
|
|
19
20
|
const CLI_FIXES: Readonly<Record<CliErrorCode, string>> = {
|
|
20
21
|
X_CLI_UNKNOWN_COMMAND: 'x help --json',
|
|
21
|
-
|
|
22
|
+
// Runnable first, the narrowing behind a `#`: `x help <command> --json` pasted into a shell
|
|
23
|
+
// is a redirect, not a command, and this table is copied verbatim by whoever reads it.
|
|
24
|
+
X_CLI_BAD_FLAG: 'x help --json # then narrow to the command the cause names',
|
|
22
25
|
X_VERIFY_FAILED: 'x verify --json',
|
|
23
26
|
X_NOT_IN_APP: 'x new myapp --json && cd myapp',
|
|
24
27
|
X_BUN_VERSION: 'bun upgrade',
|
|
25
28
|
X_NOT_IMPLEMENTED: 'x doctor --json',
|
|
26
|
-
|
|
29
|
+
// Core's three env codes, answered by the command that covers each. `X_CONFIG_INVALID` gets
|
|
30
|
+
// `x doctor` rather than `x env check`: its causes are env *and* `app.config.ts` fields, and
|
|
31
|
+
// `x env check` on a config the app cannot boot on would throw this same code straight back.
|
|
32
|
+
X_CONFIG_INVALID: 'x doctor --json',
|
|
33
|
+
X_ENV_MISSING: 'x env check --json',
|
|
34
|
+
X_ENV_EXAMPLE_DRIFT: 'x env example --json',
|
|
35
|
+
X_TEST_NO_FILES: 'x test --json # from the repo root, or pass --cwd to it',
|
|
27
36
|
X_TEST_SHARD_FAILED: 'x test --workers 1 --json',
|
|
28
|
-
X_SCAFFOLD_PATH_ESCAPE: 'x g route
|
|
37
|
+
X_SCAFFOLD_PATH_ESCAPE: 'x g route posts --json # a path with no ".." segment',
|
|
29
38
|
X_GENERATE_JSON_INVALID:
|
|
30
39
|
'bun test packages/cli/src/cmd-generate.test.ts # the error names the template to fix',
|
|
31
|
-
X_APP_PACKAGE_INVALID: 'bun pm pkg set name
|
|
40
|
+
X_APP_PACKAGE_INVALID: 'bun pm pkg set name=my-app version=0.1.0',
|
|
32
41
|
X_ERROR_CODE_UNKNOWN: 'x errors list --json',
|
|
33
42
|
X_DECLARATION_UNKNOWN: 'x actions list --json',
|
|
34
43
|
X_JOB_UNKNOWN: 'x jobs ls --json',
|
|
@@ -41,20 +50,41 @@ const CLI_FIXES: Readonly<Record<CliErrorCode, string>> = {
|
|
|
41
50
|
X_TYPECHECK_FAILED: 'bunx tsc -b --pretty false',
|
|
42
51
|
X_LINT_FAILED: 'bunx biome check --write .',
|
|
43
52
|
X_TEST_FAILED: 'x test --json # the finding carries the exact bun test invocation that failed',
|
|
53
|
+
// The same two edits `vanishedSuiteFinding` names, verbatim, so both surfaces of this code hand
|
|
54
|
+
// an agent one instruction. Neither edit is scripted here on purpose: a command that rewrites
|
|
55
|
+
// x.verify.json is the gate editing its own ratchet, which is the false green the floor closes.
|
|
56
|
+
X_VERIFY_SUITE_VANISHED:
|
|
57
|
+
'x verify --json # restore the suite, or drop its name from x.verify.json in the commit that says why',
|
|
44
58
|
X_FILE_TOO_LONG: 'x verify --json # the finding names the file to split',
|
|
45
|
-
X_PACKAGE_SHAPE: 'bun run
|
|
59
|
+
X_PACKAGE_SHAPE: 'bun run verify --json # every finding carries its own new-package.ts command',
|
|
60
|
+
// NOT `bunx tsc -b`: an unreferenced package is one `tsc -b` skips by definition, so it exits 0
|
|
61
|
+
// while the finding stands — a fix that runs clean and changes nothing is the failure axiom 4
|
|
62
|
+
// exists to prevent. The gate is what re-emits the finding, whose own `fix:` carries the exact
|
|
63
|
+
// `{ "path": … }` entry; the `tsc -b` that then reports the type errors the package had been
|
|
64
|
+
// hiding is a step of the same run.
|
|
65
|
+
X_PACKAGE_UNREFERENCED:
|
|
66
|
+
'x verify --json # the package-shape finding carries the tsconfig.json entry to add',
|
|
46
67
|
X_RELEASE_VERSION_SKEW: 'bun run scripts/release.ts --bump patch --dry-run --json',
|
|
68
|
+
// Two real remedies and the command cannot know which one this deployment wants, so it names
|
|
69
|
+
// the one that inspects the binding rather than guessing between a volume and a bucket.
|
|
70
|
+
X_STORAGE_UNWRITABLE: 'x doctor --json',
|
|
71
|
+
X_STORAGE_SECRET_DEV: 'export STORAGE_SIGNING_SECRET="$(openssl rand -hex 32)"',
|
|
47
72
|
X_MANIFEST_STALE: 'x manifest --json',
|
|
48
|
-
|
|
73
|
+
// `--target static`, not a bare `x build`: `--target` defaults to `docker`, and only the static
|
|
74
|
+
// target runs `apps/web/prerender.ts` — the one caller of `writeBuildStats`. Without the flag
|
|
75
|
+
// this fix builds an image, writes no `.x/build-stats.json`, and the next `x verify` reports the
|
|
76
|
+
// same code. Byte-identical to `checkBudgets`'s own finding, which is the other half of the pair.
|
|
77
|
+
X_BUDGET_UNMEASURED: 'x build --target static --json && x verify --json',
|
|
49
78
|
X_BUILD_FAILED: 'x build --json # the finding names the failing step',
|
|
50
79
|
X_BUILD_ENTRY_MISSING:
|
|
51
|
-
'x new
|
|
80
|
+
'x new scratch-app --dry-run --json # the file list names every entry a build needs',
|
|
52
81
|
X_DEPLOY_FAILED: 'x deploy --json # the finding carries the command to re-run directly',
|
|
53
82
|
// The container's own environment, so the answer is the run that sets it — never an `x` command,
|
|
54
83
|
// which is not what is running when a `ROLE=wroker` pod refuses to boot.
|
|
55
|
-
X_ROLE_UNKNOWN: 'docker run -e ROLE=web
|
|
56
|
-
X_PORT_INVALID: 'docker run -e PORT=3000
|
|
57
|
-
|
|
84
|
+
X_ROLE_UNKNOWN: 'docker run -e ROLE=web my-app:latest',
|
|
85
|
+
X_PORT_INVALID: 'docker run -e PORT=3000 my-app:latest',
|
|
86
|
+
X_RUNTIME_DRIVER_SPLIT: 'x dev --json # the boot names the driver the app installed twice',
|
|
87
|
+
X_GENERATE_CONFLICT: 'x g route posts --force --json',
|
|
58
88
|
X_PORT_IN_USE: 'x dev --port 3001 --json',
|
|
59
89
|
// Not `x db status`: there is no such subcommand (`x db` is gen, migrate, reset, studio, branch),
|
|
60
90
|
// so the fix answered a failed step with X_CLI_UNKNOWN_COMMAND. `x doctor` is what reports
|
|
@@ -63,20 +93,91 @@ const CLI_FIXES: Readonly<Record<CliErrorCode, string>> = {
|
|
|
63
93
|
X_DB_MIGRATE_FAILED: 'x doctor --json # cause carries the Postgres error verbatim',
|
|
64
94
|
X_DB_BRANCH_FAILED: 'x db branch ls --json',
|
|
65
95
|
X_DB_STUDIO_FAILED: 'x doctor --json',
|
|
66
|
-
X_BOUNDARY_SITE_TO_APP:
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
96
|
+
X_BOUNDARY_SITE_TO_APP:
|
|
97
|
+
'x verify --json # then: x fix boundary <the file the finding names> --json',
|
|
98
|
+
X_BOUNDARY_SHARED_LEAF:
|
|
99
|
+
'x verify --json # then: x fix boundary <the file the finding names> --json',
|
|
100
|
+
X_BOUNDARY_APP_TO_API:
|
|
101
|
+
'x verify --json # then: x fix boundary <the file the finding names> --json',
|
|
102
|
+
X_BOUNDARY_ROUTE_TO_DB:
|
|
103
|
+
'x verify --json # then: x fix boundary <the file the finding names> --json',
|
|
104
|
+
X_BOUNDARY_SERVICE_TO_HTTP:
|
|
105
|
+
'x verify --json # then: x fix boundary <the file the finding names> --json',
|
|
106
|
+
// The app's own guards. All three are reported by the gate and by nothing else, so the runnable
|
|
107
|
+
// half is the gate — the narrowing behind the `#` is the edit, because only the finding knows
|
|
108
|
+
// which file in `guards/` is the one to open.
|
|
109
|
+
X_GUARD_INVALID: 'x verify --json # then export a `guard` from the file the finding names',
|
|
110
|
+
X_GUARD_FAILED: 'x verify --json # the cause carries the throw the guard raised, verbatim',
|
|
111
|
+
X_GUARD_FINDING_INVALID:
|
|
112
|
+
'x verify --json # then give the finding an X_ code, a cause and a fix naming a command',
|
|
113
|
+
// `EDITOR=` inline rather than `export`: the variable is only needed for the one invocation, and
|
|
114
|
+
// an agent copying this line gets a working command instead of a shell it has to keep.
|
|
115
|
+
X_SECRETS_EDITOR_MISSING: 'EDITOR=nano x secrets edit',
|
|
116
|
+
X_SECRETS_EDIT_FAILED: 'x secrets show --json # then re-open the buffer: x secrets edit',
|
|
117
|
+
// Render's code, thrown here by the bundler half: the cause names the specifier and the file it
|
|
118
|
+
// resolved to, and `x g island` is what puts that file where the page already says it is.
|
|
119
|
+
X_ISLAND_INVALID: 'x routes --json # the cause names the src; then: x g island <name>',
|
|
71
120
|
};
|
|
72
121
|
|
|
73
122
|
const isCliCode = (code: string): code is CliErrorCode =>
|
|
74
123
|
(CLI_ERROR_CODES as readonly string[]).includes(code);
|
|
75
124
|
|
|
125
|
+
/**
|
|
126
|
+
* The fix a code's own throw site writes, for every code this table does not own.
|
|
127
|
+
*
|
|
128
|
+
* The table above stays the answer for `CliErrorCode` and only for it: those lines are typed,
|
|
129
|
+
* build-enforced, and several of them are deliberately NOT the throw site's wording (the comments
|
|
130
|
+
* above say which and why). Everywhere else the throw site is the definition and this is a
|
|
131
|
+
* projection of it — one `fix:`, written once, `x errors explain` and the raised error agreeing by
|
|
132
|
+
* construction rather than by review.
|
|
133
|
+
*
|
|
134
|
+
* Both fallbacks name what they do not know. `x verify --json` was the old answer for all 327 of
|
|
135
|
+
* them, and it is a lie for every runtime code: the gate does not raise `X_UNAUTHENTICATED`, so
|
|
136
|
+
* running it reports green and the reader is exactly where they started.
|
|
137
|
+
*/
|
|
138
|
+
function projectedFix(code: string): string {
|
|
139
|
+
const sites = codeFixes().get(code) ?? [];
|
|
140
|
+
const readable = sites.filter((site) => site.fix !== undefined);
|
|
141
|
+
const first = readable[0];
|
|
142
|
+
if (first?.fix === undefined) {
|
|
143
|
+
const site = sites[0];
|
|
144
|
+
if (site !== undefined) {
|
|
145
|
+
// The file comes FIRST and carries no verb. `open …` read as a command — `open(1)`,
|
|
146
|
+
// `xdg-open` — and an agent that executed it got `command not found`, which is the same
|
|
147
|
+
// axiom-4 failure as the `x verify --json` this replaced, one step further along. There is
|
|
148
|
+
// genuinely no command here: the fix is assembled from values only the raised error holds.
|
|
149
|
+
// "A file they can open" is the error contract's own fourth shape (`COMMAND_TOKENS`), and
|
|
150
|
+
// citing a command that does not really fix it is the mistake `fix-command.ts` warns about.
|
|
151
|
+
// `x errors explain --json` carries the same site as DATA, so nothing has to parse this.
|
|
152
|
+
return `${site.at}:${site.line} — the fix is built there out of values only the raised error carries, so reproduce the error and read its own fix line`;
|
|
153
|
+
}
|
|
154
|
+
if (codeFixScan() === 'unread') {
|
|
155
|
+
// Not "nothing raises it": nothing LOOKED. `cmd-docs.ts` answers the same broken install
|
|
156
|
+
// with the same line, because it is the same condition seen from a second command.
|
|
157
|
+
return `bun install && x doctor --json # the installed @ultimat3 packages could not be read, so no throw site could be quoted for ${code}`;
|
|
158
|
+
}
|
|
159
|
+
return `x errors list --json # nothing in the installed framework raises ${code}, so the package that registered it owns its fix`;
|
|
160
|
+
}
|
|
161
|
+
// Both notes name a thing this answer does NOT know, because an answer that hides either is one
|
|
162
|
+
// an agent acts on without noticing: which of several throw sites it is quoting, and which words
|
|
163
|
+
// in it were an interpolation at the throw site and are a placeholder here.
|
|
164
|
+
const notes: string[] = [];
|
|
165
|
+
if (readable.length > 1) {
|
|
166
|
+
notes.push(
|
|
167
|
+
`${code} is raised at ${readable.length} sites; this one is ${first.at}:${first.line}`,
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
if (first.fix.includes('<value>')) {
|
|
171
|
+
notes.push('each <value> is filled in by the error that raises it');
|
|
172
|
+
}
|
|
173
|
+
return notes.length === 0 ? first.fix : `${first.fix} # ${notes.join('; ')}`;
|
|
174
|
+
}
|
|
175
|
+
|
|
76
176
|
/**
|
|
77
177
|
* `undefined` for a code nobody registered — the tool then answers "unknown error code", which
|
|
78
178
|
* beats an invented explanation. The framework-wide registry holds a title and a docs URL but no
|
|
79
|
-
* fix
|
|
179
|
+
* fix, so the fix comes from `error-fixes.ts`'s read of the throw sites; a caller that has not
|
|
180
|
+
* awaited `loadCodeFixes()` gets the honest fallback rather than a stale answer.
|
|
80
181
|
*/
|
|
81
182
|
export function explainErrorCode(code: string): ErrorExplanation | undefined {
|
|
82
183
|
const cli = isCliCode(code);
|
|
@@ -85,7 +186,7 @@ export function explainErrorCode(code: string): ErrorExplanation | undefined {
|
|
|
85
186
|
return {
|
|
86
187
|
code,
|
|
87
188
|
cause: described.title,
|
|
88
|
-
fix: cli ? CLI_FIXES[code] :
|
|
189
|
+
fix: cli ? CLI_FIXES[code] : projectedFix(code),
|
|
89
190
|
docs: cli ? docsFor(code) : described.docs,
|
|
90
191
|
};
|
|
91
192
|
}
|
package/src/mcp-host.ts
CHANGED
|
@@ -3,11 +3,17 @@
|
|
|
3
3
|
// the gate. The description half is the framework's own `frameworkIntrospection`, so nothing here
|
|
4
4
|
// is a second catalog of routes, entities, actions, queries or jobs.
|
|
5
5
|
|
|
6
|
-
import { existsSync } from 'node:fs';
|
|
7
6
|
import { join } from 'node:path';
|
|
8
|
-
import { agentActor, UltimateError } from '@ultimat3/core';
|
|
7
|
+
import { agentActor, isUltimateError, UltimateError } from '@ultimat3/core';
|
|
9
8
|
import type { DbClient } from '@ultimat3/db';
|
|
10
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
ensureReadOnlyRole,
|
|
11
|
+
isLedgerMissing,
|
|
12
|
+
migrate,
|
|
13
|
+
pendingMigrations,
|
|
14
|
+
readLedger,
|
|
15
|
+
readOnlyQuery,
|
|
16
|
+
} from '@ultimat3/db';
|
|
11
17
|
import { inspectJobList, inspectQueues } from '@ultimat3/jobs';
|
|
12
18
|
import { MANIFEST_FILENAME } from '@ultimat3/manifest';
|
|
13
19
|
import type {
|
|
@@ -28,13 +34,14 @@ import type { RunningServices } from './dev-runtime';
|
|
|
28
34
|
import { startServices } from './dev-runtime';
|
|
29
35
|
import type { DevServices, Env } from './dev-services';
|
|
30
36
|
import { resolveServices } from './dev-services';
|
|
31
|
-
import {
|
|
37
|
+
import { loadCodeFixes } from './error-fixes';
|
|
32
38
|
import { CliNotImplementedError } from './errors';
|
|
33
39
|
import type { Runner } from './exec';
|
|
34
40
|
import { execOutput } from './exec';
|
|
35
41
|
import { databaseTarget } from './mcp-db-target';
|
|
36
42
|
import { explainErrorCode } from './mcp-errors';
|
|
37
43
|
import { parseBunTest } from './mcp-test-output';
|
|
44
|
+
import { readMigrations } from './migrations';
|
|
38
45
|
|
|
39
46
|
export interface DevHostInput {
|
|
40
47
|
readonly root: string;
|
|
@@ -106,20 +113,25 @@ export function lazyServices(input: DevHostInput): LazyServices {
|
|
|
106
113
|
};
|
|
107
114
|
}
|
|
108
115
|
|
|
109
|
-
/**
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
116
|
+
/**
|
|
117
|
+
* Migration ids on disk that the ledger does not record. Both halves are the framework's own —
|
|
118
|
+
* `readMigrations` is the list `ROLE=migrate` applies and `pendingMigrations` is the filter
|
|
119
|
+
* `migrate()` applies it through, so this tool can never report a pending set the migrator would
|
|
120
|
+
* disagree with.
|
|
121
|
+
*/
|
|
122
|
+
async function pendingIds(root: string, lazy: LazyServices): Promise<readonly string[]> {
|
|
123
|
+
const migrations = await readMigrations(root);
|
|
124
|
+
if (migrations.length === 0) return [];
|
|
117
125
|
const { db } = await lazy.running();
|
|
118
126
|
// No ledger table means nothing has been applied. `ensureLedger` would create it, and a dry run
|
|
119
|
-
// is not allowed to write.
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
127
|
+
// is not allowed to write. Only that condition: a permission denied or an unreachable server is
|
|
128
|
+
// a ledger nobody read, and answering it with `[]` reports every migration as pending against a
|
|
129
|
+
// database whose state this tool never saw.
|
|
130
|
+
const ledger = await readLedger(db).catch((error: unknown) => {
|
|
131
|
+
if (!isLedgerMissing(error)) throw error;
|
|
132
|
+
return [];
|
|
133
|
+
});
|
|
134
|
+
return pendingMigrations(ledger, migrations).map((migration) => migration.id);
|
|
123
135
|
}
|
|
124
136
|
|
|
125
137
|
// ── the capabilities ─────────────────────────────────────────────────────────
|
|
@@ -164,7 +176,7 @@ function capabilities(input: DevHostInput, lazy: LazyServices): DevCapabilities
|
|
|
164
176
|
let readOnlyRole: Promise<string | null> | undefined;
|
|
165
177
|
|
|
166
178
|
return {
|
|
167
|
-
database: databaseTarget(lazy.services),
|
|
179
|
+
database: databaseTarget(lazy.services, input.env),
|
|
168
180
|
|
|
169
181
|
async runQuery(sql: string, limits: QueryLimits): Promise<QueryRows> {
|
|
170
182
|
const { db } = await lazy.running();
|
|
@@ -175,20 +187,24 @@ function capabilities(input: DevHostInput, lazy: LazyServices): DevCapabilities
|
|
|
175
187
|
},
|
|
176
188
|
|
|
177
189
|
async runMigrations(branch: string, dryRun: boolean) {
|
|
178
|
-
const before = await
|
|
190
|
+
const before = await pendingIds(root, lazy);
|
|
179
191
|
if (dryRun) return { branch, applied: [], pending: before };
|
|
180
|
-
const
|
|
181
|
-
|
|
192
|
+
const { db } = await lazy.running();
|
|
193
|
+
try {
|
|
194
|
+
await migrate({ migrations: await readMigrations(root), client: db });
|
|
195
|
+
} catch (error) {
|
|
182
196
|
// Thrown, not returned: `server.ts` renders any X_* error as the three-line
|
|
183
|
-
// code/cause/fix result, which is what an agent needs to act without a round trip.
|
|
197
|
+
// code/cause/fix result, which is what an agent needs to act without a round trip. The
|
|
198
|
+
// engine's own errors already carry that shape and pass through untouched.
|
|
199
|
+
if (isUltimateError(error)) throw error;
|
|
184
200
|
throw new UltimateError({
|
|
185
201
|
code: 'X_DB_MIGRATE_FAILED',
|
|
186
|
-
cause:
|
|
202
|
+
cause: error instanceof Error ? error.message : String(error),
|
|
187
203
|
fix: 'x db reset',
|
|
188
204
|
});
|
|
189
205
|
}
|
|
190
|
-
// The ledger is the evidence for "applied" — never the migrator's own
|
|
191
|
-
const pending = await
|
|
206
|
+
// The ledger is the evidence for "applied" — never the migrator's own return value.
|
|
207
|
+
const pending = await pendingIds(root, lazy);
|
|
192
208
|
return { branch, applied: before.filter((id) => !pending.includes(id)), pending };
|
|
193
209
|
},
|
|
194
210
|
|
|
@@ -270,7 +286,10 @@ function capabilities(input: DevHostInput, lazy: LazyServices): DevCapabilities
|
|
|
270
286
|
* introspection tool then answers from the framework's own registries, not from a scan.
|
|
271
287
|
*/
|
|
272
288
|
export async function createDevMcpServer(input: DevHostInput): Promise<CliMcpServer> {
|
|
273
|
-
|
|
289
|
+
// `explainError` is synchronous by `DevCapabilities`' own signature, so the walk that reads the
|
|
290
|
+
// framework's `fix:` lines happens here, once, or `errors.explain` answers with the fallback for
|
|
291
|
+
// every code it could have quoted.
|
|
292
|
+
await Promise.all([loadApp(input.root), loadCodeFixes()]);
|
|
274
293
|
const lazy = lazyServices(input);
|
|
275
294
|
const introspection = frameworkIntrospection({
|
|
276
295
|
routes: () => describeRoutes(),
|
package/src/messages.ts
CHANGED
|
@@ -10,6 +10,7 @@ const CATALOG = {
|
|
|
10
10
|
'cli.flags.heading': 'flags',
|
|
11
11
|
'cli.commands.heading': 'commands',
|
|
12
12
|
'cli.build.done': 'built {target}',
|
|
13
|
+
'cli.build.failed': '{target} build failed',
|
|
13
14
|
// `describeCron`'s vocabulary. `@ultimat3/time` is tier 1 and reaches no i18n runtime, so the
|
|
14
15
|
// caller supplies the words — and the caller here is a rendered `x tasks show` line, which is
|
|
15
16
|
// exactly what this catalog holds. `msg()` leaves an un-supplied `{n}`/`{time}`/`{days}`/
|
|
@@ -24,7 +25,31 @@ const CATALOG = {
|
|
|
24
25
|
'cli.cron.inMonths': 'in {months}',
|
|
25
26
|
'cli.cron.onDaysOfMonth': 'on day {days} of the month',
|
|
26
27
|
'cli.cron.onWeekdays': 'on {days}',
|
|
28
|
+
'cli.db.backfill.empty': 'no backfill has run against this database yet',
|
|
29
|
+
'cli.db.backfill.listed': '{count} backfill pass(es)',
|
|
30
|
+
/** The empty cell in a `x db backfill --list` column — a value, not a column key. */
|
|
31
|
+
'cli.db.backfill.none': '-',
|
|
32
|
+
'cli.db.backfill.pending': '{count} of {declared} declared backfill(s) never completed',
|
|
33
|
+
'cli.db.backfill.swept': 'every one of {declared} declared backfill(s) has completed',
|
|
34
|
+
// Every action counted, never derived: `count - enqueued` folded a deduped pass into "blocked",
|
|
35
|
+
// so the summary said blocked while `--json` said deduped for the same row — two renderers
|
|
36
|
+
// stating different facts about one run, which is the thing `--json` exists to make impossible.
|
|
37
|
+
'cli.db.backfill.planned':
|
|
38
|
+
'{count} backfill(s): {enqueued} enqueued, {deduped} already live, {blocked} blocked',
|
|
39
|
+
'cli.db.backfill.dryRun': '{count} backfill(s) would run — nothing written without --write',
|
|
27
40
|
'cli.db.branch.ready': 'branch {name} ready',
|
|
41
|
+
'cli.db.branch.dropped': 'branch {name} dropped',
|
|
42
|
+
'cli.db.branch.failed': 'branch command failed',
|
|
43
|
+
'cli.db.branch.listed': '{count} branch(es) of this database',
|
|
44
|
+
'cli.db.branch.none': 'this database has no branch',
|
|
45
|
+
/** The empty cell in an `x db branch ls` column — a value, not a column key. */
|
|
46
|
+
'cli.db.branch.unknown': '-',
|
|
47
|
+
'cli.db.gen.failed': 'migration not generated',
|
|
48
|
+
'cli.db.gen.unchanged': 'entities and migrations agree — nothing to generate',
|
|
49
|
+
'cli.db.gen.written': 'migration {id} generated',
|
|
50
|
+
'cli.db.migrate.applied': 'migrations applied',
|
|
51
|
+
'cli.db.migrate.failed': 'migration failed',
|
|
52
|
+
'cli.db.reset.done': 'database reset and migrated',
|
|
28
53
|
'cli.dev.ready': 'dev ready on {url} — /_x mounted ({panels} panels), {services}',
|
|
29
54
|
// The mail and CDN halves of that boot line. Rendered text, so it lives here — while
|
|
30
55
|
// `describeMail`/`describeCdn` keep the same wording as the fixed vocabulary `x dev --json`
|
|
@@ -33,6 +58,7 @@ const CATALOG = {
|
|
|
33
58
|
'cli.dev.cdn.none': 'cdn=none',
|
|
34
59
|
'cli.dev.mail.embedded': 'mail=embedded',
|
|
35
60
|
'cli.dev.mail.external': 'mail=external({driver} via {detail})',
|
|
61
|
+
'cli.dev.mail.refused': 'mail=refused({detail})',
|
|
36
62
|
'cli.dev.hmr': 'reloaded {file} in {ms}ms',
|
|
37
63
|
'cli.dev.roles': ' roles {roles}',
|
|
38
64
|
'cli.dev.panels': ' panels {panels}',
|
|
@@ -41,17 +67,42 @@ const CATALOG = {
|
|
|
41
67
|
'cli.deploy.plan': 'containers only: {images} image, roles {roles}',
|
|
42
68
|
'cli.doctor.clean': 'no findings — environment is shippable',
|
|
43
69
|
'cli.doctor.findings': '{count} finding(s)',
|
|
70
|
+
'cli.docs.code': '{code} is an error code — x errors explain answers it',
|
|
71
|
+
'cli.docs.exports': 'exports: {list}',
|
|
72
|
+
'cli.docs.installed': 'installed: {list}',
|
|
73
|
+
'cli.docs.tryErrors': 'every X_* code, with its fix',
|
|
74
|
+
'cli.docs.tryActions': "this app's own primitives, not the framework's",
|
|
75
|
+
'cli.docs.found': '{count} doc(s) for "{query}"',
|
|
76
|
+
'cli.docs.none': 'no framework doc matches "{query}"',
|
|
77
|
+
'cli.docs.unresolved': 'the installed framework packages could not be located',
|
|
44
78
|
'cli.errors.count': '{count} registered error code(s)',
|
|
45
79
|
'cli.errors.explained': '{code} — {title}',
|
|
80
|
+
// One rendering of "this file was written", for every command that writes files — `x g`, its
|
|
81
|
+
// own `--dry-run`, and `x new`. Three copies of the same two characters is how a fourth writer
|
|
82
|
+
// arrives with a fifth marker; `--json` carries the paths themselves in `data.files`.
|
|
83
|
+
'cli.file.added': ' + {path}',
|
|
46
84
|
'cli.fix.clean': 'no boundary violation involves {file}',
|
|
47
|
-
|
|
85
|
+
// "nothing written", the same admission `cli.generate.planned` makes and for the same reason: a
|
|
86
|
+
// command called `fix` that only ever REPORTS teaches an agent to expect a repair and act as
|
|
87
|
+
// though one happened. There is no `--write` and there is not going to be one
|
|
88
|
+
// (`docs/architecture/02-boundaries.md`), so the line that runs says so every time.
|
|
89
|
+
'cli.fix.plan':
|
|
90
|
+
'{count} boundary violation(s) involve {file} — {edits} edit(s) to make, nothing written',
|
|
48
91
|
'cli.generate.wrote': 'wrote {count} file(s) for {kind} {name}',
|
|
92
|
+
// A distinct key, not the same sentence with a flag beside it: `--dry-run` reported "wrote 4
|
|
93
|
+
// file(s)" while `data.dryRun` said nothing had landed, so an agent branching on `summary`
|
|
94
|
+
// believed the files were on disk.
|
|
95
|
+
'cli.generate.planned': 'would write {count} file(s) for {kind} {name} — nothing written',
|
|
49
96
|
'cli.i18n.added': 'added {locale} — {keys} key(s) seeded from {from}',
|
|
50
97
|
'cli.i18n.dynamic': '{count} dynamic t() call(s) the extractor cannot verify:',
|
|
51
98
|
'cli.i18n.gaps': '{missing} missing key(s) across {locales} locale(s)',
|
|
52
99
|
'cli.i18n.ok': '{locales} locale(s), {keys} key(s) used — no gaps',
|
|
53
100
|
'cli.i18n.synced': 'synced {locale} from {from} — {added} key(s) added, {total} total',
|
|
54
101
|
'cli.i18n.unused': '{count} key(s) defined in {locale} and never used:',
|
|
102
|
+
'cli.jobs.backfillNoCursor': 'no cursor yet',
|
|
103
|
+
'cli.jobs.cancelled': 'job {id} cancelled — {state}',
|
|
104
|
+
'cli.jobs.backfillRow': '{name} — {rows} row(s) so far, cursor {cursor}',
|
|
105
|
+
'cli.jobs.backfills': '{count} backfill(s) in flight:',
|
|
55
106
|
'cli.jobs.deadLetters': '{count} dead letter(s):',
|
|
56
107
|
'cli.jobs.depth':
|
|
57
108
|
'{ready} ready · {running} running · {delayed} delayed · {dead} dead across {queues} queue(s)',
|
|
@@ -69,7 +120,10 @@ const CATALOG = {
|
|
|
69
120
|
'cli.manifest.wrote': 'manifest written to {path} ({routes} routes, {actions} actions)',
|
|
70
121
|
'cli.mcp.serving': 'mcp {transport} serving {tools} tools',
|
|
71
122
|
'cli.mcp.scopes': ' scopes {scopes}',
|
|
72
|
-
|
|
123
|
+
// `x db gen "initial"` is a first step, not an optional one: the scaffold writes no migration, so
|
|
124
|
+
// the app has a schema no migration records and `x verify`'s drift step says so until it runs.
|
|
125
|
+
'cli.new.done':
|
|
126
|
+
'created {name} — next: cd {name} && bun install && x db gen "initial" && x db migrate && x dev',
|
|
73
127
|
'cli.policy.count':
|
|
74
128
|
'{permissions} permission(s), {roles} role(s), {enforced} enforced by a declaration',
|
|
75
129
|
// One row per (declaration, actor) pair, never per role: a permission two declarations enforce
|
|
@@ -97,6 +151,31 @@ const CATALOG = {
|
|
|
97
151
|
'cli.test.type.pass': '{type} — {files} test file(s) on {workers} worker(s) passed in {ms}ms',
|
|
98
152
|
'cli.verify.pass': 'all {count} steps passed in {ms}ms',
|
|
99
153
|
'cli.verify.fail': '{failed} of {count} steps failed',
|
|
154
|
+
// A skipped step is not a passed one, so the two counts never share a sentence — and the skipped
|
|
155
|
+
// ones are named, because "which suite has nothing to run here?" is the question a green gate
|
|
156
|
+
// over a missing suite has to answer on its own line. Whole sentences per case rather than a
|
|
157
|
+
// clause the caller glues on, the same shape `cli.jobs.drained`/`drainedPartial` already uses.
|
|
158
|
+
'cli.verify.passSkipped':
|
|
159
|
+
'{passed} of {count} steps passed in {ms}ms — {skipped} skipped: {names}',
|
|
160
|
+
'cli.verify.failSkipped': '{failed} of {count} steps failed — {skipped} skipped: {names}',
|
|
161
|
+
'cli.verify.serial': 'serial',
|
|
162
|
+
'cli.verify.workers': '{workers} workers',
|
|
163
|
+
'cli.env.checked': '{count} declared variable(s), all present and valid',
|
|
164
|
+
'cli.env.invalid': '{count} of {total} declared variable(s) missing or malformed',
|
|
165
|
+
'cli.env.wrote': 'wrote {path} — {count} declared variable(s)',
|
|
166
|
+
'cli.env.fresh': '{path} already matches the declaration',
|
|
167
|
+
'cli.secrets.init': 'sealed {path} — master key {kid}, and .gitignore now covers the key file',
|
|
168
|
+
'cli.secrets.deploy': ' carry the key into a deploy with {env}="$(cat {keyPath})"',
|
|
169
|
+
'cli.secrets.redeploy': ' set {env}="$(cat {keyPath})" in every deploy before the next release',
|
|
170
|
+
'cli.secrets.shown': '{count} secret(s) in {path}, sealed with master key {kid}',
|
|
171
|
+
'cli.secrets.empty': '{path} holds no secrets yet',
|
|
172
|
+
'cli.secrets.undeclared':
|
|
173
|
+
'{count} secret(s) no envSchema declares, so nothing reads them: {names}',
|
|
174
|
+
'cli.secrets.edited': '{path} resealed — {added} added, {updated} changed, {removed} removed',
|
|
175
|
+
'cli.secrets.unchanged': '{path} unchanged — nothing was written',
|
|
176
|
+
'cli.secrets.set': 'sealed {name} into {path} — {count} secret(s)',
|
|
177
|
+
'cli.secrets.rotated':
|
|
178
|
+
'rotated {path} from master key {from} to {to} — {count} secret(s) resealed',
|
|
100
179
|
} as const;
|
|
101
180
|
|
|
102
181
|
export type MessageKey = keyof typeof CATALOG;
|
package/src/metrics-endpoint.ts
CHANGED
|
@@ -14,9 +14,10 @@ import {
|
|
|
14
14
|
* A port of its own, and NOT the role's HTTP port, for one reason the chart makes concrete:
|
|
15
15
|
* `docker/helm/templates/ingress.yaml` routes `path: /` `Prefix` to the web Service, so a
|
|
16
16
|
* `/metrics` mounted beside `/healthz` on port 3000 is `/metrics` on the internet — route
|
|
17
|
-
* patterns, request volumes and error rates, published.
|
|
18
|
-
* `
|
|
19
|
-
*
|
|
17
|
+
* patterns, request volumes and error rates, published. `service.yaml` does publish this port
|
|
18
|
+
* so a `ServiceMonitor` has a named target, but `ingress.yaml` selects its backend port BY NAME
|
|
19
|
+
* (`http`), so the endpoint stays cluster-internal by construction rather than by an ingress
|
|
20
|
+
* exclusion somebody has to remember to write.
|
|
20
21
|
*
|
|
21
22
|
* It is also the only thing `worker`, `scheduler` and `replicator` could ever be scraped on —
|
|
22
23
|
* they open no HTTP socket at all, and `queue_depth` is exactly the signal one of them owns.
|
package/src/migrations.ts
CHANGED
|
@@ -4,11 +4,18 @@
|
|
|
4
4
|
|
|
5
5
|
import { existsSync } from 'node:fs';
|
|
6
6
|
import { join } from 'node:path';
|
|
7
|
-
import type { Migration } from '@ultimat3/db';
|
|
7
|
+
import type { Migration, SchemaDescription } from '@ultimat3/db';
|
|
8
|
+
import { parseSnapshot } from '@ultimat3/db';
|
|
8
9
|
|
|
9
|
-
/** Where `x
|
|
10
|
+
/** Where `x db gen` — the only writer — puts them. App-root-relative, POSIX. */
|
|
10
11
|
export const MIGRATIONS_DIR = 'packages/db/migrations';
|
|
11
12
|
|
|
13
|
+
/** The schema `<id>` leaves behind, written by `x db gen` so the next one can diff against it. */
|
|
14
|
+
export const snapshotFileName = (id: string): string => `${id}.snapshot.json`;
|
|
15
|
+
|
|
16
|
+
/** The hash of the entity source `<id>` was generated from. `drift.ts` writes and reads it. */
|
|
17
|
+
export const hashFileName = (id: string): string => `${id}.hash`;
|
|
18
|
+
|
|
12
19
|
/**
|
|
13
20
|
* `-- down` alone on a line splits a migration file. Anchored to the whole line so a comment that
|
|
14
21
|
* merely mentions the word — `-- down migrations are required` — is not mistaken for the marker.
|
|
@@ -25,21 +32,47 @@ export function parseMigrationSql(id: string, sql: string): Migration {
|
|
|
25
32
|
return { id, name: migrationName(id), up, down };
|
|
26
33
|
}
|
|
27
34
|
|
|
35
|
+
/**
|
|
36
|
+
* A snapshot that will not parse is *absent*, never a half-read one: the `up` beside it is still
|
|
37
|
+
* the migration this app applies, so the file list stays whole. What that absence then means is
|
|
38
|
+
* the caller's — `x db gen` refuses with `X_MIGRATION_SNAPSHOT_MISSING` when it is the newest
|
|
39
|
+
* migration's, because there is nothing left to diff the entities against.
|
|
40
|
+
*/
|
|
41
|
+
async function readSnapshot(dir: string, id: string): Promise<SchemaDescription | undefined> {
|
|
42
|
+
const file = Bun.file(join(dir, snapshotFileName(id)));
|
|
43
|
+
if (!(await file.exists())) return undefined;
|
|
44
|
+
// Parsed to the last nested field by `@ultimat3/db`, never asserted: `{"tables":[null]}` is
|
|
45
|
+
// valid JSON and a cast made it a `SchemaDescription` the diff then threw on.
|
|
46
|
+
return parseSnapshot(await file.json().catch(() => undefined));
|
|
47
|
+
}
|
|
48
|
+
|
|
28
49
|
/**
|
|
29
50
|
* Sorted by id, because that is the apply order and `pendingMigrations` re-sorts on the same key.
|
|
30
51
|
* A missing directory is an empty list rather than a throw: an app can legitimately declare no
|
|
31
52
|
* entity yet, and the count is reported so "nothing was applied" is never silent.
|
|
53
|
+
*
|
|
54
|
+
* `<id>.down.sql` is skipped and never read as a migration of its own. Migrations before 1.2.0
|
|
55
|
+
* were hand-written as a `<id>.sql` / `<id>.down.sql` pair — a layout no generator ever produced
|
|
56
|
+
* and this reader would have applied as a migration named `<id>.down`, dropping every table the
|
|
57
|
+
* pair exists to reverse. One migration is one file, split by the `-- down` marker.
|
|
32
58
|
*/
|
|
33
59
|
export async function readMigrations(root: string): Promise<readonly Migration[]> {
|
|
34
60
|
const dir = join(root, MIGRATIONS_DIR);
|
|
35
61
|
if (!existsSync(dir)) return [];
|
|
36
62
|
const files: string[] = [];
|
|
37
|
-
for await (const file of new Bun.Glob('*.sql').scan({ cwd: dir }))
|
|
63
|
+
for await (const file of new Bun.Glob('*.sql').scan({ cwd: dir })) {
|
|
64
|
+
if (!file.endsWith('.down.sql')) files.push(file);
|
|
65
|
+
}
|
|
38
66
|
files.sort();
|
|
39
67
|
const migrations: Migration[] = [];
|
|
40
68
|
for (const file of files) {
|
|
69
|
+
const id = file.replace(/\.sql$/, '');
|
|
41
70
|
const text = await Bun.file(join(dir, file)).text();
|
|
42
|
-
|
|
71
|
+
const snapshot = await readSnapshot(dir, id);
|
|
72
|
+
migrations.push({
|
|
73
|
+
...parseMigrationSql(id, text),
|
|
74
|
+
...(snapshot === undefined ? {} : { snapshot }),
|
|
75
|
+
});
|
|
43
76
|
}
|
|
44
77
|
return migrations;
|
|
45
78
|
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// OTLP export, switched on by the variable the shipped Helm chart already sets. Until this file
|
|
2
|
+
// `OTEL_EXPORTER_OTLP_ENDPOINT` was in `docker/helm/values.yaml` and **no code read it**: a
|
|
3
|
+
// deployment configured a collector, the collector received nothing, and the only signal that
|
|
4
|
+
// anything was wrong was an empty dashboard.
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
configureMetrics,
|
|
8
|
+
configureTelemetry,
|
|
9
|
+
logger,
|
|
10
|
+
onShutdown,
|
|
11
|
+
otlpMetricExporter,
|
|
12
|
+
otlpSpanExporter,
|
|
13
|
+
startMetricExport,
|
|
14
|
+
tryOtlpEndpoint,
|
|
15
|
+
} from '@ultimat3/core';
|
|
16
|
+
import type { Env } from './dev-services';
|
|
17
|
+
|
|
18
|
+
/** How often counters are pushed. Core's own default; named here because the boot chose it. */
|
|
19
|
+
export const METRIC_EXPORT_INTERVAL_MS = 60_000;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Install whichever exporters an endpoint was configured for, and return the release.
|
|
23
|
+
*
|
|
24
|
+
* `tryOtlpEndpoint` is asked FIRST, per signal, because both constructors throw
|
|
25
|
+
* `X_OTLP_ENDPOINT_INVALID` when nothing configured one — deliberately, so that a telemetry
|
|
26
|
+
* exporter can never silently send nowhere. Asking is what makes the exporter optional without
|
|
27
|
+
* making it silent.
|
|
28
|
+
*
|
|
29
|
+
* Both are registered with `onShutdown(..., { phase: 'close' })`: the last spans of a drain are
|
|
30
|
+
* the ones that explain the drain, and a process that exits with a full queue loses exactly the
|
|
31
|
+
* window an operator went looking for.
|
|
32
|
+
*/
|
|
33
|
+
export function startOtlpExport(env: Env = process.env): () => void {
|
|
34
|
+
const releases: (() => void)[] = [];
|
|
35
|
+
|
|
36
|
+
// The boot's OWN env, not `process.env`, and the resolved endpoint is then passed to the
|
|
37
|
+
// exporter explicitly: `runRole({ env })` is a real seam — a test and an in-process host both
|
|
38
|
+
// pass an env that is not the process's — and an exporter that re-read `process.env` would
|
|
39
|
+
// answer a different question from the one this function just asked.
|
|
40
|
+
const traces = tryOtlpEndpoint('traces', env);
|
|
41
|
+
if (traces !== undefined) {
|
|
42
|
+
const exporter = otlpSpanExporter({ endpoint: traces });
|
|
43
|
+
configureTelemetry({ exporter });
|
|
44
|
+
releases.push(onShutdown('otlp-traces', () => exporter.shutdown(), { phase: 'close' }));
|
|
45
|
+
logger.info('ultimate otlp traces', { endpoint: traces });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const metrics = tryOtlpEndpoint('metrics', env);
|
|
49
|
+
if (metrics !== undefined) {
|
|
50
|
+
const exporter = otlpMetricExporter({ endpoint: metrics });
|
|
51
|
+
configureMetrics({ exporter });
|
|
52
|
+
// The push loop, and not only the exporter: `configureMetrics` names where a snapshot goes
|
|
53
|
+
// and nothing decides when one is taken, so without this the collector receives one export —
|
|
54
|
+
// the drain's — for the whole life of the process.
|
|
55
|
+
const stopTimer = startMetricExport(METRIC_EXPORT_INTERVAL_MS);
|
|
56
|
+
releases.push(stopTimer);
|
|
57
|
+
releases.push(onShutdown('otlp-metrics', () => exporter.flush(), { phase: 'close' }));
|
|
58
|
+
logger.info('ultimate otlp metrics', { endpoint: metrics });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return () => {
|
|
62
|
+
for (const release of releases.reverse()) release();
|
|
63
|
+
};
|
|
64
|
+
}
|