@ultimat3/cli 23.0.0 → 25.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 +28 -13
- package/README.md +32 -7
- package/package.json +29 -29
- package/src/admin-catalog-keys.ts +2 -2
- package/src/affected.ts +30 -36
- package/src/api-registration.ts +89 -0
- package/src/app-artifacts.ts +1 -1
- package/src/app-auth.ts +4 -27
- package/src/app-boundaries.ts +17 -4
- package/src/app-config-load.ts +51 -0
- package/src/app-env.ts +26 -25
- package/src/app-load.ts +38 -14
- package/src/app-manifest.ts +4 -10
- package/src/app-mcp.ts +31 -46
- package/src/app-name.ts +52 -0
- package/src/app-permissions-borrowed.ts +123 -0
- package/src/app-permissions-site.ts +48 -0
- package/src/app-permissions.ts +7 -17
- package/src/app-root.ts +9 -5
- package/src/bin.ts +3 -7
- package/src/browser-launcher.ts +4 -2
- package/src/budgets-charged.ts +60 -0
- package/src/budgets-scan.ts +76 -0
- package/src/budgets-unmeasured.ts +91 -0
- package/src/budgets.ts +117 -154
- package/src/cdp-shot-driver.ts +20 -1
- package/src/cdp-shot-watch.ts +35 -31
- package/src/ci-log.ts +0 -0
- package/src/cmd-auth-spec.ts +16 -0
- package/src/cmd-auth.ts +65 -0
- package/src/cmd-build-spec.ts +7 -1
- package/src/cmd-build.ts +151 -22
- package/src/cmd-db-backfill.ts +26 -13
- package/src/cmd-db-branch.ts +2 -2
- package/src/cmd-db.ts +10 -6
- package/src/cmd-deploy-compose.ts +94 -0
- package/src/cmd-deploy-drain.ts +48 -0
- package/src/cmd-deploy-helm.ts +7 -6
- package/src/cmd-deploy-start-first.ts +178 -0
- package/src/cmd-deploy.ts +158 -36
- package/src/cmd-dev.ts +18 -356
- package/src/cmd-doctor-spec.ts +2 -9
- package/src/cmd-doctor.ts +57 -80
- package/src/cmd-env.ts +55 -16
- package/src/cmd-generate.ts +89 -32
- package/src/cmd-help.ts +19 -6
- package/src/cmd-i18n.ts +21 -17
- package/src/cmd-jobs-spec.ts +7 -22
- package/src/cmd-jobs.ts +33 -140
- package/src/cmd-manifest-spec.ts +1 -1
- package/src/cmd-manifest.ts +6 -25
- package/src/cmd-mcp.ts +71 -22
- package/src/cmd-new.ts +36 -8
- package/src/cmd-planned.ts +24 -8
- package/src/cmd-routes.ts +15 -5
- package/src/cmd-secrets.ts +47 -6
- package/src/cmd-shot-matrix.ts +22 -7
- package/src/cmd-shot-spec.ts +10 -2
- package/src/cmd-shot.ts +46 -41
- package/src/cmd-tasks.ts +2 -2
- package/src/cmd-test-spec.ts +1 -7
- package/src/cmd-test.ts +14 -30
- package/src/cmd-verify.ts +4 -4
- package/src/coverage-lcov.ts +4 -2
- package/src/db-backfill.ts +2 -2
- package/src/db-replay-engine.ts +4 -10
- package/src/db-subscribes.ts +27 -16
- package/src/dev-boot.ts +290 -0
- package/src/dev-child-watch.ts +63 -12
- package/src/dev-dashboard-guard.ts +76 -0
- package/src/dev-dashboard.ts +56 -30
- package/src/dev-lock.ts +100 -31
- package/src/dev-rebuild.ts +80 -0
- package/src/dev-restart-retire.ts +46 -0
- package/src/dev-route-table.ts +30 -8
- package/src/dev-supervisor.ts +114 -22
- package/src/dev-traces.ts +53 -20
- package/src/dispatch.ts +17 -13
- package/src/doctor-app-url.ts +39 -0
- package/src/doctor-auth.ts +95 -0
- package/src/doctor-offline.ts +6 -5
- package/src/document-options.ts +7 -0
- package/src/drift-replica-identity.ts +42 -0
- package/src/drift.ts +29 -6
- package/src/error-catalog.ts +6 -9
- package/src/error-codes.ts +29 -3
- package/src/error-page-csp.ts +14 -15
- package/src/error-unthrown.ts +29 -11
- package/src/errors.ts +54 -83
- package/src/exec.ts +8 -0
- package/src/favicon.ts +2 -2
- package/src/fix-command.ts +9 -1
- package/src/framework-env.ts +148 -0
- package/src/framework-schema-apply.ts +78 -0
- package/src/framework-schema-stamp.ts +96 -0
- package/src/framework-schema.ts +25 -3
- package/src/generate-catalog-locales.ts +79 -0
- package/src/generate-files.ts +4 -3
- package/src/generate-kinds.ts +18 -0
- package/src/generate-plural.ts +50 -0
- package/src/generate-shadow.ts +95 -0
- package/src/generate-write.ts +47 -13
- package/src/gh-target.ts +12 -7
- package/src/hold.ts +56 -1
- package/src/i18n-audit.ts +25 -9
- package/src/i18n-index.ts +5 -6
- package/src/i18n-registration.ts +7 -9
- package/src/import-scan.ts +28 -3
- package/src/index.ts +2 -6
- package/src/island-bundle.ts +57 -79
- package/src/island-capture.ts +1 -1
- package/src/island-duplicate-refusal.ts +57 -0
- package/src/island-duplicates.ts +221 -0
- package/src/island-entry-missing.ts +64 -0
- package/src/island-harness-route.ts +20 -4
- package/src/island-harness-script.ts +23 -2
- package/src/island-identity.ts +17 -14
- package/src/island-package-dedupe.ts +150 -0
- package/src/island-realtime.ts +36 -16
- package/src/island-routes.ts +2 -2
- package/src/island-runtime.ts +241 -0
- package/src/island-sources.ts +105 -0
- package/src/island-states-load.ts +6 -0
- package/src/island-store.ts +90 -6
- package/src/jobs-json.ts +0 -26
- package/src/jobs-report.ts +36 -20
- package/src/local-cli-handoff.ts +90 -0
- package/src/loopback-host.ts +13 -0
- package/src/manifest-staleness.ts +62 -0
- package/src/mcp-errors.ts +20 -4
- package/src/mcp-host.ts +22 -14
- package/src/mcp-test-run.ts +38 -0
- package/src/mcp-ui-inspect.ts +3 -2
- package/src/mcp-ui-interact.ts +3 -2
- package/src/mcp-ui.ts +8 -7
- package/src/measure-scope.ts +15 -10
- package/src/messages.ts +29 -16
- package/src/metrics-endpoint.ts +28 -3
- package/src/migration-extensions.ts +1 -1
- package/src/otlp-export.ts +2 -2
- package/src/output.ts +24 -0
- package/src/page-navigation.ts +7 -23
- package/src/page-speculation.ts +19 -31
- package/src/parse.ts +0 -8
- package/src/permission-grants.ts +3 -3
- package/src/posix-path.ts +30 -0
- package/src/prerender-locales.ts +6 -4
- package/src/prerender-out.ts +34 -7
- package/src/prerender-report.ts +58 -0
- package/src/prerender-served.ts +52 -0
- package/src/prerender.ts +71 -103
- package/src/pwa-artifacts.ts +33 -94
- package/src/pwa-manifest-locales.ts +9 -9
- package/src/registry.ts +2 -0
- package/src/retry-memo.ts +1 -1
- package/src/revalidated-response.ts +4 -3
- package/src/role-realtime.ts +7 -1
- package/src/role-replicator.ts +124 -29
- package/src/role-start.ts +69 -85
- package/src/role-sync.ts +119 -25
- package/src/root-env.ts +157 -28
- package/src/route-islands.ts +69 -0
- package/src/runtime-admin.ts +4 -1
- package/src/runtime-assets.ts +26 -9
- package/src/runtime-bindings.ts +27 -7
- package/src/runtime-cache.ts +46 -55
- package/src/runtime-hooks.ts +2 -1
- package/src/runtime-idempotency-scope.ts +23 -0
- package/src/runtime-isr-outcome.ts +100 -0
- package/src/runtime-isr.ts +36 -0
- package/src/runtime-jobs.ts +76 -41
- package/src/runtime-live-feed.ts +1 -1
- package/src/runtime-mail.ts +26 -0
- package/src/runtime-mfa-warning.ts +29 -0
- package/src/runtime-notify-retention.ts +10 -46
- package/src/runtime-overrides.ts +4 -4
- package/src/runtime-purge.ts +38 -16
- package/src/runtime-queue.ts +43 -22
- package/src/runtime-realtime.ts +11 -45
- package/src/runtime-render.ts +136 -114
- package/src/runtime-replica.ts +3 -8
- package/src/runtime-services.ts +49 -26
- package/src/runtime-storage-upload.ts +2 -2
- package/src/runtime-storage.ts +13 -8
- package/src/schema-diff.ts +16 -0
- package/src/schema-drift.ts +45 -15
- package/src/secrets-rotation.ts +8 -9
- package/src/seo-routes.ts +80 -9
- package/src/serve-boot.ts +51 -12
- package/src/serve-drain.ts +11 -33
- package/src/serve-env.ts +9 -6
- package/src/serve-retire.ts +95 -0
- package/src/serve-web.ts +31 -15
- package/src/serve.ts +27 -10
- package/src/server-barrels.ts +6 -3
- package/src/shot-browser.ts +1 -1
- package/src/shot-locale.ts +6 -33
- package/src/shot-settle.ts +3 -3
- package/src/shot-theme.ts +1 -1
- package/src/shot-viewport.ts +152 -0
- package/src/signal-shred.ts +26 -12
- package/src/site-asset-routes.ts +2 -2
- package/src/site-assets.ts +3 -5
- package/src/site-config.ts +9 -48
- package/src/site-seo.ts +7 -5
- package/src/sitemap-lastmod.ts +5 -0
- package/src/solid-loader.ts +12 -3
- package/src/source-files.ts +19 -3
- package/src/static-report.ts +5 -0
- package/src/stored-object-headers.ts +43 -0
- package/src/style-routes.ts +2 -2
- package/src/sw-artifacts.ts +8 -8
- package/src/sw-precache-plan.ts +34 -4
- package/src/sync-authenticator.ts +3 -3
- package/src/sync-url.ts +3 -3
- package/src/tasks-facts.ts +3 -29
- package/src/templates/action.ts +7 -4
- package/src/templates/backfill.ts +4 -4
- package/src/templates/entity.ts +2 -2
- package/src/templates/github/ci.yml.ts +28 -18
- package/src/templates/github/image.yml.ts +92 -0
- package/src/templates/github/runner.ts +18 -0
- package/src/templates/guard-bare-error.ts +16 -2
- package/src/templates/guard-unzoned-date.ts +56 -6
- package/src/templates/index.ts +1 -1
- package/src/templates/job-body-test.ts +2 -2
- package/src/templates/job.ts +6 -6
- package/src/templates/naming.ts +28 -0
- package/src/templates/query.ts +9 -8
- package/src/templates/resource-page.ts +9 -7
- package/src/templates/resource-service.ts +2 -2
- package/src/templates/route.ts +26 -14
- package/src/templates/scaffold-api.ts +2 -3
- package/src/templates/scaffold-app.ts +8 -3
- package/src/templates/scaffold-auth.ts +205 -70
- package/src/templates/scaffold-bin.ts +122 -0
- package/src/templates/scaffold-claude-commands.ts +11 -11
- package/src/templates/scaffold-container-compose.ts +10 -1
- package/src/templates/scaffold-container.ts +16 -8
- package/src/templates/scaffold-dashboard-bare.ts +8 -4
- package/src/templates/scaffold-dashboard-example-tests.ts +208 -0
- package/src/templates/scaffold-dashboard-example.ts +112 -172
- package/src/templates/scaffold-dashboard-shared.ts +62 -1
- package/src/templates/scaffold-dashboard.ts +9 -5
- package/src/templates/scaffold-db-client.ts +4 -2
- package/src/templates/scaffold-db-package.ts +2 -2
- package/src/templates/scaffold-docs.ts +20 -77
- package/src/templates/scaffold-domain-package.ts +0 -4
- package/src/templates/scaffold-entries.ts +4 -3
- package/src/templates/scaffold-env.ts +6 -3
- package/src/templates/scaffold-gitattributes.ts +59 -0
- package/src/templates/scaffold-helm-fences.ts +76 -0
- package/src/templates/scaffold-helm-templates.ts +100 -19
- package/src/templates/scaffold-helm.ts +62 -5
- package/src/templates/scaffold-http.ts +1 -1
- package/src/templates/scaffold-i18n.ts +15 -2
- package/src/templates/scaffold-icon.ts +2 -2
- package/src/templates/scaffold-repo.ts +27 -16
- package/src/templates/scaffold-shell.ts +55 -37
- package/src/test-counts.ts +2 -6
- package/src/test-ignores.ts +25 -0
- package/src/test-only-shipped.ts +193 -0
- package/src/test-passes.ts +0 -15
- package/src/test-select.ts +7 -15
- package/src/test-shards.ts +40 -95
- package/src/test-workers.ts +26 -4
- package/src/theme-boot.ts +8 -33
- package/src/theme-brand.ts +66 -0
- package/src/trusted-hops-error.ts +17 -0
- package/src/trusted-hops.ts +49 -0
- package/src/unscanned-admin.ts +2 -2
- package/src/unthrown-codes.ts +26 -0
- package/src/verify-checks.ts +29 -48
- package/src/verify-coverage-run.ts +3 -2
- package/src/verify-deadline.ts +207 -33
- package/src/verify-merge.ts +108 -43
- package/src/verify-part-step.ts +98 -0
- package/src/verify-role-load-probe.ts +12 -2
- package/src/verify-role-load.ts +9 -4
- package/src/verify-run.ts +32 -8
- package/src/verify-shard.ts +13 -2
- package/src/verify-stalled.ts +152 -0
- package/src/verify-step.ts +2 -0
- package/src/verify-test-run.ts +3 -2
- package/src/verify-tests.ts +5 -3
- package/src/worker-bundle.ts +2 -2
- package/src/workspace-checks.ts +53 -7
- package/src/workspace-graph.ts +3 -2
- package/src/write-line.ts +82 -7
- package/src/browser-launcher-fake-html.ts +0 -80
- package/src/browser-launcher-fake.ts +0 -168
- package/src/build-errors.ts +0 -54
- package/src/jobs-drain.ts +0 -163
- package/src/scaffold-typecheck.ts +0 -227
- package/src/thrown-by.ts +0 -24
- package/src/verify-errors.ts +0 -58
package/src/jobs-report.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
// Pure, driver-injected job operations behind `x jobs`: flag parsing, plus ls / show / retry. No
|
|
2
|
-
// CLI parsing, no process I/O, no rendering — a test drives every path with `
|
|
3
|
-
// alone.
|
|
2
|
+
// CLI parsing, no process I/O, no rendering — a test drives every path with `memoryJobDriver()`
|
|
3
|
+
// alone. The `--json` shapes are `jobs-json.ts`, the table `jobs-table.ts`.
|
|
4
4
|
|
|
5
|
+
import type { Page } from '@ultimat3/core';
|
|
6
|
+
import { pageOf } from '@ultimat3/core';
|
|
5
7
|
import type {
|
|
6
8
|
BackfillProgress,
|
|
7
9
|
DeadLetterEntry,
|
|
@@ -13,6 +15,7 @@ import type {
|
|
|
13
15
|
QueueDepthReport,
|
|
14
16
|
} from '@ultimat3/jobs';
|
|
15
17
|
import {
|
|
18
|
+
DEFAULT_JOB_PAGE,
|
|
16
19
|
inspectBackfills,
|
|
17
20
|
inspectDeadLetters,
|
|
18
21
|
inspectJob,
|
|
@@ -20,19 +23,12 @@ import {
|
|
|
20
23
|
inspectQueues,
|
|
21
24
|
isJobState,
|
|
22
25
|
JOB_STATES,
|
|
26
|
+
jobCursor,
|
|
27
|
+
MAX_JOB_PAGE,
|
|
23
28
|
retryFromStep,
|
|
24
29
|
} from '@ultimat3/jobs';
|
|
25
30
|
import { BadFlagError, JobUnknownError } from './errors';
|
|
26
31
|
|
|
27
|
-
/**
|
|
28
|
-
* The queue's own vocabulary, re-exported rather than restated — this file carried a copy of it,
|
|
29
|
-
* and the copy was one member short. `cancelled` shipped in `@ultimat3/jobs` and never here, so
|
|
30
|
-
* `x jobs cancel` created a state `x jobs ls --state cancelled` then refused to filter on: two
|
|
31
|
-
* commands of one CLI disagreeing about what a job can be. Kept on this module's surface because
|
|
32
|
-
* `index.ts` exports it from here.
|
|
33
|
-
*/
|
|
34
|
-
export { JOB_STATES } from '@ultimat3/jobs';
|
|
35
|
-
|
|
36
32
|
export function parseStateFlag(value: string | undefined): JobState | undefined {
|
|
37
33
|
if (value === undefined) return undefined;
|
|
38
34
|
if (isJobState(value)) return value;
|
|
@@ -76,12 +72,32 @@ export interface JobsListFilter {
|
|
|
76
72
|
readonly after?: string | undefined;
|
|
77
73
|
}
|
|
78
74
|
|
|
79
|
-
|
|
75
|
+
/** ONE page of rows — core's `Page`, so `nextCursor` exists exactly when another row does. */
|
|
76
|
+
export type JobsListResult = Page<JobRecord> & {
|
|
80
77
|
readonly depth: QueueDepthReport;
|
|
81
|
-
readonly rows: readonly JobRecord[];
|
|
82
78
|
readonly deadLetters: readonly DeadLetterEntry[];
|
|
83
79
|
/** The sweeps still in flight — see below for why finished ones are not this command's answer. */
|
|
84
80
|
readonly backfills: readonly BackfillProgress[];
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* One page, and whether a row exists past it — asked of the queue, never guessed from a count.
|
|
85
|
+
* `next = rows.length === limit ? cursor : null` handed a FULL last page a cursor to an empty one.
|
|
86
|
+
* The page reads one row past its limit; at `MAX_JOB_PAGE` there is no room for that row (the
|
|
87
|
+
* queue refuses a bigger page), so a full maximal page asks for the one row after its last.
|
|
88
|
+
*/
|
|
89
|
+
async function jobPage(driver: JobDriver, filter: JobFilter): Promise<Page<JobRecord>> {
|
|
90
|
+
const size = filter.limit ?? DEFAULT_JOB_PAGE;
|
|
91
|
+
const ask = size < MAX_JOB_PAGE ? size + 1 : size;
|
|
92
|
+
const fetched = await inspectJobList(driver, { ...filter, limit: ask });
|
|
93
|
+
const rows = fetched.slice(0, size);
|
|
94
|
+
const last = rows.at(-1);
|
|
95
|
+
if (last === undefined || fetched.length < size) return pageOf(rows, null);
|
|
96
|
+
if (fetched.length > size) return pageOf(rows, jobCursor(last));
|
|
97
|
+
if (ask > size) return pageOf(rows, null);
|
|
98
|
+
const after = jobCursor(last);
|
|
99
|
+
const beyond = await inspectJobList(driver, { ...filter, limit: 1, after });
|
|
100
|
+
return pageOf(rows, beyond.length > 0 ? after : null);
|
|
85
101
|
}
|
|
86
102
|
|
|
87
103
|
/**
|
|
@@ -107,13 +123,13 @@ export async function listJobs(
|
|
|
107
123
|
...(limit === undefined ? {} : { limit }),
|
|
108
124
|
...(filter.after === undefined ? {} : { after: filter.after }),
|
|
109
125
|
};
|
|
110
|
-
const [depth,
|
|
126
|
+
const [depth, page, deadLetters, backfills] = await Promise.all([
|
|
111
127
|
inspectQueues(driver),
|
|
112
|
-
|
|
128
|
+
jobPage(driver, jobFilter),
|
|
113
129
|
inspectDeadLetters(driver),
|
|
114
130
|
inspectBackfills(driver, { status: 'running' }),
|
|
115
131
|
]);
|
|
116
|
-
return {
|
|
132
|
+
return { ...page, depth, deadLetters, backfills };
|
|
117
133
|
}
|
|
118
134
|
|
|
119
135
|
// ── show ──────────────────────────────────────────────────────────────────
|
|
@@ -127,10 +143,10 @@ export async function showJob(driver: JobDriver, id: string): Promise<JobTrace>
|
|
|
127
143
|
// ── retry ─────────────────────────────────────────────────────────────────
|
|
128
144
|
|
|
129
145
|
/**
|
|
130
|
-
* Existence is checked up front so an unknown id always surfaces as `X_JOB_UNKNOWN`:
|
|
131
|
-
*
|
|
132
|
-
*
|
|
133
|
-
*
|
|
146
|
+
* Existence is checked up front so an unknown id always surfaces as `X_JOB_UNKNOWN`: both
|
|
147
|
+
* drivers answer `X_JOB_NOT_FOUND` from inside `requeue()` for a missing row, and that code is
|
|
148
|
+
* the driver's contract, not this command's — `retryFromStep`'s documented `undefined` return
|
|
149
|
+
* is, and the driver never reaches it once the row is already known absent.
|
|
134
150
|
*/
|
|
135
151
|
export async function retryJob(
|
|
136
152
|
driver: JobDriver,
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// Running the app's own CLI in place of this one (`local-cli.ts` decides WHETHER). The parent only
|
|
2
|
+
// waits, so every stop signal it receives is the child's to act on: a supervisor or a closing
|
|
3
|
+
// terminal signals the pid it started, and that pid is this one. `bin.ts` exits with the answer.
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The stop signals forwarded to the child at once. SIGINT is NOT one of them: Ctrl-C reaches the
|
|
7
|
+
* whole foreground process group, so the child already has it, and a second one landed inside its
|
|
8
|
+
* cleanup — `x secrets edit` died mid-shred with its decrypted buffer still on disk (audit M1).
|
|
9
|
+
*/
|
|
10
|
+
export const HAND_OFF_SIGNALS = ['SIGTERM', 'SIGHUP'] as const;
|
|
11
|
+
export type HandOffSignal = (typeof HAND_OFF_SIGNALS)[number] | 'SIGINT';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* How long a SIGINT is held before it is forwarded. A Ctrl-C the group delivered ends the child
|
|
15
|
+
* well inside it — a shred is milliseconds — so it is never sent twice; a SIGINT aimed at this pid
|
|
16
|
+
* alone (`kill -INT`, a runtime whose STOPSIGNAL is SIGINT) reaches the child a second later
|
|
17
|
+
* instead of never. A child still draining past it gets one more SIGINT, which every drain here
|
|
18
|
+
* already absorbs (`signal-shred.ts` ignores repeats; `x dev`'s stop is armed once).
|
|
19
|
+
*/
|
|
20
|
+
export const INTERRUPT_GRACE_MS = 1000;
|
|
21
|
+
|
|
22
|
+
/** Arms `fire` after `ms`, answering its cancel. Injected by the test, so no verdict waits on a clock. */
|
|
23
|
+
export type Schedule = (fire: () => void, ms: number) => () => void;
|
|
24
|
+
|
|
25
|
+
/** The spawned CLI as the hand-off sees it — `Bun.spawn`'s answer, narrowed so a test can fake it. */
|
|
26
|
+
export interface HandOffChild {
|
|
27
|
+
readonly exited: Promise<number>;
|
|
28
|
+
kill(signal: HandOffSignal): void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Untimed by design, and counted under `cli`'s pin in `scripts/lib/spawn-timeout-pins.ts`: the
|
|
32
|
+
// child IS the command the user typed (`x dev` serves until Ctrl-C), so its lifetime is theirs, and
|
|
33
|
+
// the forwarded stop signals below are what end it.
|
|
34
|
+
const spawnInherited = (
|
|
35
|
+
command: readonly string[],
|
|
36
|
+
env: Readonly<Record<string, string | undefined>>,
|
|
37
|
+
): HandOffChild =>
|
|
38
|
+
Bun.spawn([...command], { stdin: 'inherit', stdout: 'inherit', stderr: 'inherit', env });
|
|
39
|
+
|
|
40
|
+
const scheduleTimer: Schedule = (fire, ms) => {
|
|
41
|
+
const timer = setTimeout(fire, ms);
|
|
42
|
+
return () => clearTimeout(timer);
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export interface HandOffOptions {
|
|
46
|
+
readonly spawn?: typeof spawnInherited;
|
|
47
|
+
readonly schedule?: Schedule;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Spawns `command`, forwards SIGTERM and SIGHUP to it for as long as it runs — SIGINT only once the
|
|
52
|
+
* grace passes with the child still running — and answers its exit code: `128 + n` when a signal
|
|
53
|
+
* ended it, as `Bun.spawn`'s `exited` reports. `bin.ts` exits with that code.
|
|
54
|
+
*
|
|
55
|
+
* Without the listeners the default action ran: the parent died on the signal and the child —
|
|
56
|
+
* an `x dev` holding a port and an embedded Postgres, an `x db migrate` mid-transaction — ran on
|
|
57
|
+
* as an orphan nothing would stop. A listener is also what keeps the parent alive to report the
|
|
58
|
+
* child's own exit code, rather than 143 before the child had drained.
|
|
59
|
+
*/
|
|
60
|
+
export async function handOff(
|
|
61
|
+
command: readonly string[],
|
|
62
|
+
env: Readonly<Record<string, string | undefined>>,
|
|
63
|
+
options: HandOffOptions = {},
|
|
64
|
+
): Promise<number> {
|
|
65
|
+
const child = (options.spawn ?? spawnInherited)(command, env);
|
|
66
|
+
const schedule = options.schedule ?? scheduleTimer;
|
|
67
|
+
const forwards = HAND_OFF_SIGNALS.map((signal) => {
|
|
68
|
+
const forward = (): void => child.kill(signal);
|
|
69
|
+
process.on(signal, forward);
|
|
70
|
+
return () => process.off(signal, forward);
|
|
71
|
+
});
|
|
72
|
+
// Whether the child already has this SIGINT is unknowable from here, so the grace decides: a
|
|
73
|
+
// repeat inside one window arms nothing new — one Ctrl-C never becomes two forwarded signals.
|
|
74
|
+
let cancelGrace: (() => void) | undefined;
|
|
75
|
+
const holdInterrupt = (): void => {
|
|
76
|
+
if (cancelGrace !== undefined) return;
|
|
77
|
+
cancelGrace = schedule(() => {
|
|
78
|
+
cancelGrace = undefined;
|
|
79
|
+
child.kill('SIGINT');
|
|
80
|
+
}, INTERRUPT_GRACE_MS);
|
|
81
|
+
};
|
|
82
|
+
process.on('SIGINT', holdInterrupt);
|
|
83
|
+
try {
|
|
84
|
+
return await child.exited;
|
|
85
|
+
} finally {
|
|
86
|
+
for (const release of forwards) release();
|
|
87
|
+
process.off('SIGINT', holdInterrupt);
|
|
88
|
+
cancelGrace?.();
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// Whether a hostname is this machine: `localhost`, a `*.localhost` name (RFC 6761 reserves both
|
|
2
|
+
// to loopback) or a loopback address literal. One answer for `x dev`'s Host check and `x doctor`'s
|
|
3
|
+
// APP_URL rule, which ask the same question about two different headers.
|
|
4
|
+
|
|
5
|
+
import { classifyAddress } from '@ultimat3/core';
|
|
6
|
+
|
|
7
|
+
/** `hostname` as `URL` spells it — `[::1]` bracketed, no port. */
|
|
8
|
+
export const isLoopbackHostname = (hostname: string): boolean => {
|
|
9
|
+
const name = hostname.toLowerCase();
|
|
10
|
+
return (
|
|
11
|
+
name === 'localhost' || name.endsWith('.localhost') || classifyAddress(name) === 'loopback'
|
|
12
|
+
);
|
|
13
|
+
};
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// Do the committed generated files still describe the code? ONE check, asked by `x manifest
|
|
2
|
+
// --check` and by the gate's `manifest` step through this function — two commands answering
|
|
3
|
+
// differently about one file is itself the drift these files exist to prevent.
|
|
4
|
+
|
|
5
|
+
// why: Bun has no synchronous existence probe, and `hasCommittedContract` guards an app load.
|
|
6
|
+
import { existsSync } from 'node:fs';
|
|
7
|
+
// why: Bun exposes no path-join primitive; Bun.file takes a path already joined.
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
import { ERROR_DOCS_URL } from '@ultimat3/core';
|
|
10
|
+
import type { Manifest } from '@ultimat3/manifest';
|
|
11
|
+
import { assertNoDrift, MANIFEST_FILENAME } from '@ultimat3/manifest';
|
|
12
|
+
import { OPENAPI_FILE, openApiStaleness } from './app-openapi';
|
|
13
|
+
import { APP_CONFIG_FILE } from './app-root';
|
|
14
|
+
import type { Finding } from './output';
|
|
15
|
+
import { findingFrom } from './output';
|
|
16
|
+
|
|
17
|
+
/** Whether there is any committed contract here to judge — the cheap question, asked first. */
|
|
18
|
+
export const hasCommittedContract = (root: string): boolean =>
|
|
19
|
+
existsSync(join(root, MANIFEST_FILENAME)) || existsSync(join(root, OPENAPI_FILE));
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* `x.manifest.json` was never written. An app root only: the framework monorepo emits
|
|
23
|
+
* `framework.manifest.json` and has no `x.manifest.json` to be missing. `AGENTS.md` tells an agent
|
|
24
|
+
* that facts live in this file, and after `x new` and every generator nothing had run the one
|
|
25
|
+
* command that writes it while the gate reported green (#F7).
|
|
26
|
+
*/
|
|
27
|
+
export function manifestMissingFindings(root: string): readonly Finding[] {
|
|
28
|
+
if (!existsSync(join(root, APP_CONFIG_FILE))) return [];
|
|
29
|
+
if (existsSync(join(root, MANIFEST_FILENAME))) return [];
|
|
30
|
+
return [
|
|
31
|
+
{
|
|
32
|
+
code: 'X_MANIFEST_MISSING',
|
|
33
|
+
cause: `${MANIFEST_FILENAME} does not exist, so every fact an agent reads about this app — route table, action schemas, policies, error codes — is unavailable`,
|
|
34
|
+
fix: 'x manifest',
|
|
35
|
+
docs: ERROR_DOCS_URL,
|
|
36
|
+
at: MANIFEST_FILENAME,
|
|
37
|
+
},
|
|
38
|
+
];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Every way the committed files disagree with `manifest`, the app as the code declares it now:
|
|
43
|
+
* `x.manifest.json` missing (`X_MANIFEST_MISSING`) or drifted (`X_MANIFEST_DRIFT` — a body that no
|
|
44
|
+
* longer hashes to its own `buildId` included), and `openapi.json` or a bearer mount's document
|
|
45
|
+
* stale (`X_MANIFEST_STALE`). The spec is judged whether or not the manifest is there: it is a
|
|
46
|
+
* published contract on its own, and the typed client is generated from it.
|
|
47
|
+
*/
|
|
48
|
+
export async function manifestStaleness(
|
|
49
|
+
root: string,
|
|
50
|
+
manifest: Manifest,
|
|
51
|
+
): Promise<readonly Finding[]> {
|
|
52
|
+
const path = join(root, MANIFEST_FILENAME);
|
|
53
|
+
const findings: Finding[] = [...manifestMissingFindings(root)];
|
|
54
|
+
if (existsSync(path)) {
|
|
55
|
+
try {
|
|
56
|
+
await assertNoDrift({ manifest, path });
|
|
57
|
+
} catch (error) {
|
|
58
|
+
findings.push({ ...findingFrom(error), at: MANIFEST_FILENAME });
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return [...findings, ...(await openApiStaleness(root, manifest))];
|
|
62
|
+
}
|
package/src/mcp-errors.ts
CHANGED
|
@@ -51,7 +51,9 @@ const CLI_FIXES: Readonly<Record<CliErrorCode, string>> = {
|
|
|
51
51
|
'x verify --only unit --json # the finding carries the measured numbers and the "coverage" line to add to x.verify.json',
|
|
52
52
|
X_COVERAGE_FLOOR_STALE:
|
|
53
53
|
'x verify --only unit --json # the finding carries the measured numbers; raise "coverage" in x.verify.json to them',
|
|
54
|
-
|
|
54
|
+
// Unthrown since `--worker` was deleted, and kept only because this record is typed over every
|
|
55
|
+
// registered code: `--shard i/n` is the one way to rerun a slice.
|
|
56
|
+
X_TEST_SHARD_FAILED: 'x verify --only unit --shard 1/2 --json',
|
|
55
57
|
X_SCAFFOLD_PATH_ESCAPE: 'x g route posts --json # a path with no ".." segment',
|
|
56
58
|
X_GENERATE_JSON_INVALID:
|
|
57
59
|
'bun test packages/cli/src/cmd-generate.test.ts # the error names the template to fix',
|
|
@@ -67,7 +69,8 @@ const CLI_FIXES: Readonly<Record<CliErrorCode, string>> = {
|
|
|
67
69
|
'x verify --json # the package-shape finding carries the dependency line to add',
|
|
68
70
|
X_PACKAGE_DUPLICATED:
|
|
69
71
|
'x i18n check --json # the finding names both copies and the package.json to pin',
|
|
70
|
-
X_SHOT_BROWSER_MISSING:
|
|
72
|
+
X_SHOT_BROWSER_MISSING:
|
|
73
|
+
'export CHROME_PATH=/usr/bin/google-chrome # macOS: "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"; Windows PowerShell: $env:CHROME_PATH = "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe"',
|
|
71
74
|
X_UI_SHOT_ROUTE_UNKNOWN:
|
|
72
75
|
'x routes --json # then call the ui.* tool with one of its path values',
|
|
73
76
|
X_UI_SHOT_ROUTE_UNBUDGETED:
|
|
@@ -118,8 +121,7 @@ const CLI_FIXES: Readonly<Record<CliErrorCode, string>> = {
|
|
|
118
121
|
'x errors list --json # register the code in its package src/errors.ts, or move its row under "Reserved codes"',
|
|
119
122
|
X_ERROR_CODE_UNRESOLVED:
|
|
120
123
|
'x verify --json # the finding names the file, the line and the name it could not resolve',
|
|
121
|
-
X_ERROR_CODE_UNTHROWN:
|
|
122
|
-
'x errors explain X_ERROR_CODE_UNTHROWN --json # then mark the row "registered, thrown by nothing since <version>"',
|
|
124
|
+
X_ERROR_CODE_UNTHROWN: 'x errors explain X_ERROR_CODE_UNTHROWN --json',
|
|
123
125
|
X_CLI_UNEXPECTED: 'x doctor --json',
|
|
124
126
|
// `-p .`: an app root has no `references`, so the gate's own step runs this form (#450).
|
|
125
127
|
X_TYPECHECK_FAILED: 'bunx tsc -p . --pretty false',
|
|
@@ -284,6 +286,20 @@ const CLI_FIXES: Readonly<Record<CliErrorCode, string>> = {
|
|
|
284
286
|
'edit docker/Dockerfile: add RUN bun node_modules/@ultimat3/cli/src/bin.ts build --target prebuilt after COPY . . in the runtime stage, then rebuild the image',
|
|
285
287
|
X_ADMIN_UNSCANNED:
|
|
286
288
|
'git mv apps/admin/src/index.ts apps/admin/app/admin/admin.ts # then repoint its relative imports and x verify --only manifest --json',
|
|
289
|
+
X_FRAMEWORK_TABLE_ORPHANED: 'x doctor --json',
|
|
290
|
+
X_BUILD_OUT_UNSAFE: 'x build --target static --out .x/static --json',
|
|
291
|
+
X_FRAMEWORK_SCHEMA_UNAPPLIED: 'x db migrate --json',
|
|
292
|
+
X_APP_URL_PORT_MISMATCH: 'export APP_URL=http://localhost:3000',
|
|
293
|
+
X_DEV_HOST_REFUSED: "curl -sS -H 'accept: application/json' http://localhost:3000/_x",
|
|
294
|
+
X_TRUSTED_PROXY_HOPS_INVALID: 'TRUSTED_PROXY_HOPS=1 ROLE=web bun apps/web/server.ts',
|
|
295
|
+
X_PERMISSION_BORROWED: 'x verify --only policy --json',
|
|
296
|
+
X_ERROR_CODE_UNTHROWN_STALE: 'x errors explain X_ERROR_CODE_UNTHROWN_STALE --json',
|
|
297
|
+
X_DEV_STATE_UNWRITABLE:
|
|
298
|
+
'ls -ld .x # make it writable by this user, on a filesystem that supports hard links, then re-run x dev',
|
|
299
|
+
X_PACKAGE_TEST_ONLY_SHIPPED:
|
|
300
|
+
'bun run x -- verify --only package-shape --json # then rename each listed module to <name>-fixture.ts and update its importers',
|
|
301
|
+
X_ISLAND_MODULE_DUPLICATED:
|
|
302
|
+
'gh issue create --repo developerz-ai/ultimate --title "X_ISLAND_MODULE_DUPLICATED" # paste this error\'s --json as the body: the framework bundled one of its own modules twice',
|
|
287
303
|
};
|
|
288
304
|
|
|
289
305
|
const isCliCode = (code: string): code is CliErrorCode =>
|
package/src/mcp-host.ts
CHANGED
|
@@ -7,7 +7,14 @@
|
|
|
7
7
|
// or prints inside a `fix:` an operator runs — the dev log, a per-role log, the committed
|
|
8
8
|
// manifest — and string concatenation would answer a different path on a Windows checkout.
|
|
9
9
|
import { join } from 'node:path';
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
agentActor,
|
|
12
|
+
isUltimateError,
|
|
13
|
+
NotImplementedError,
|
|
14
|
+
renderFixShellArg,
|
|
15
|
+
renderThrowable,
|
|
16
|
+
UltimateError,
|
|
17
|
+
} from '@ultimat3/core';
|
|
11
18
|
import type { DbClient } from '@ultimat3/db';
|
|
12
19
|
import {
|
|
13
20
|
ensureReadOnlyRole,
|
|
@@ -28,19 +35,18 @@ import type {
|
|
|
28
35
|
VerifyResult,
|
|
29
36
|
VerifyStep,
|
|
30
37
|
} from '@ultimat3/mcp';
|
|
31
|
-
import {
|
|
32
|
-
import {
|
|
38
|
+
import { DEV_SCOPES, devHost, devMcpServer, frameworkIntrospection } from '@ultimat3/mcp';
|
|
39
|
+
import { describePages } from '@ultimat3/render';
|
|
33
40
|
import { loadApp } from './app-load';
|
|
34
41
|
import { appManifest, policyFacts } from './app-manifest';
|
|
35
42
|
import { runVerify, VERIFY_STEPS } from './cmd-verify';
|
|
36
43
|
import { declareDevEnvironment } from './dev-environment';
|
|
37
44
|
import { loadCodeFixes } from './error-fixes';
|
|
38
|
-
import { BadFlagError
|
|
45
|
+
import { BadFlagError } from './errors';
|
|
39
46
|
import type { Runner } from './exec';
|
|
40
|
-
import { execOutput } from './exec';
|
|
41
47
|
import { databaseTarget } from './mcp-db-target';
|
|
42
48
|
import { explainErrorCode } from './mcp-errors';
|
|
43
|
-
import {
|
|
49
|
+
import { testRunOf } from './mcp-test-run';
|
|
44
50
|
import { type UiCapabilities, uiCapabilities } from './mcp-ui';
|
|
45
51
|
import { readMigrations } from './migrations';
|
|
46
52
|
import { retryMemo } from './retry-memo';
|
|
@@ -107,8 +113,9 @@ export function lazyServices(input: DevHostInput): LazyServices {
|
|
|
107
113
|
// A boot started after close() would hold the PGlite data directory for the life of the
|
|
108
114
|
// process with nobody left to stop it — the host is closed, so the call is the bug.
|
|
109
115
|
if (closed) {
|
|
110
|
-
throw new
|
|
111
|
-
|
|
116
|
+
throw new NotImplementedError({
|
|
117
|
+
cause:
|
|
118
|
+
'an MCP tool that needs the database after the host closed is not implemented in this build',
|
|
112
119
|
fix: 'x mcp serve --transport stdio # keep the host open for the whole session',
|
|
113
120
|
});
|
|
114
121
|
}
|
|
@@ -271,7 +278,8 @@ function capabilities(
|
|
|
271
278
|
cwd: root,
|
|
272
279
|
...(Object.keys(envOverrides).length === 0 ? {} : { env: envOverrides }),
|
|
273
280
|
});
|
|
274
|
-
|
|
281
|
+
// The whole result, exit code included: `0 fail` beside `1 error` is a red run.
|
|
282
|
+
return testRunOf(result);
|
|
275
283
|
},
|
|
276
284
|
|
|
277
285
|
async tailLogs(lines: number, role: string | undefined) {
|
|
@@ -281,12 +289,12 @@ function capabilities(
|
|
|
281
289
|
if (!(await file.exists())) {
|
|
282
290
|
// `@ultimat3/core`'s `logger` is a module const with no sink seam, so there is nothing to
|
|
283
291
|
// intercept in-process — a log on disk is the only honest source, and the fix creates one.
|
|
284
|
-
throw new
|
|
285
|
-
|
|
292
|
+
throw new NotImplementedError({
|
|
293
|
+
cause: `logs.tail without ${path} is not implemented in this build`,
|
|
286
294
|
fix:
|
|
287
295
|
role === undefined
|
|
288
296
|
? `x dev > ${path} 2>&1`
|
|
289
|
-
: `mkdir -p ${join(dir, 'logs')} && x dev --role ${role} > ${path} 2>&1`,
|
|
297
|
+
: `mkdir -p ${renderFixShellArg(join(dir, 'logs'), '<the logs dir of the path the cause names>')} && x dev --role ${role} > ${path} 2>&1`,
|
|
290
298
|
});
|
|
291
299
|
}
|
|
292
300
|
return (await file.text()).trimEnd().split('\n').slice(-lines);
|
|
@@ -341,10 +349,10 @@ export async function createDevMcpServer(input: DevHostInput): Promise<CliMcpSer
|
|
|
341
349
|
const lazy = lazyServices(input);
|
|
342
350
|
const ui = uiCapabilities({ root: input.root, env: input.env });
|
|
343
351
|
const introspection = frameworkIntrospection({
|
|
344
|
-
routes: () =>
|
|
352
|
+
routes: () => describePages(),
|
|
345
353
|
policies: () => policyFacts(),
|
|
346
354
|
});
|
|
347
|
-
const server =
|
|
355
|
+
const server = devMcpServer({ host: devHost(introspection, capabilities(input, lazy, ui)) });
|
|
348
356
|
const caller = localCaller();
|
|
349
357
|
return {
|
|
350
358
|
server,
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// `test.run`'s answer from the whole `bun test` result, not from its pass/fail lines alone: bun
|
|
2
|
+
// counts an error raised OUTSIDE any test on a line of its own (`1 error`) beside `0 fail`, and
|
|
3
|
+
// says the run failed only through its exit code.
|
|
4
|
+
|
|
5
|
+
import type { TestRun } from '@ultimat3/mcp';
|
|
6
|
+
import type { ExecResult } from './exec';
|
|
7
|
+
import { execOutput } from './exec';
|
|
8
|
+
import { parseBunTest } from './mcp-test-output';
|
|
9
|
+
|
|
10
|
+
/** Bun's own `N error` / `N errors` summary line: the last one, as `parseBunTest` reads the rest. */
|
|
11
|
+
export const errorsIn = (output: string): number => {
|
|
12
|
+
const last = [...output.matchAll(/^\s*(\d+)\s+errors?\s*$/gm)].at(-1);
|
|
13
|
+
return last === undefined ? 0 : Number.parseInt(last[1] ?? '0', 10);
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Every error outside a test is one failure, carried with the run's own message; a non-zero exit
|
|
18
|
+
* with nothing else counted is one more, so a red process can never answer `failed: 0`.
|
|
19
|
+
*/
|
|
20
|
+
export function testRunOf(result: ExecResult): TestRun {
|
|
21
|
+
const output = execOutput(result);
|
|
22
|
+
const run = parseBunTest(output, result.durationMs);
|
|
23
|
+
const errors = errorsIn(output);
|
|
24
|
+
const outside = Array.from({ length: errors }, () => ({
|
|
25
|
+
test: 'bun test',
|
|
26
|
+
message: 'an error outside any test — a file that threw while loading, or a failed hook',
|
|
27
|
+
}));
|
|
28
|
+
const counted = run.failed + errors;
|
|
29
|
+
const exitOnly =
|
|
30
|
+
result.code !== 0 && counted === 0
|
|
31
|
+
? [{ test: 'bun test', message: `bun test exited ${String(result.code)} with no failure` }]
|
|
32
|
+
: [];
|
|
33
|
+
return {
|
|
34
|
+
...run,
|
|
35
|
+
failed: counted + exitOnly.length,
|
|
36
|
+
failures: [...run.failures, ...outside, ...exitOnly],
|
|
37
|
+
};
|
|
38
|
+
}
|
package/src/mcp-ui-inspect.ts
CHANGED
|
@@ -11,9 +11,10 @@
|
|
|
11
11
|
import { join } from 'node:path';
|
|
12
12
|
import type { UiInspectInput, UiInspectResult, UiInspectSelector } from '@ultimat3/mcp';
|
|
13
13
|
import { UI_INSPECT_LIMITS } from '@ultimat3/mcp';
|
|
14
|
+
import { IDLE_HYDRATE_TIMEOUT_MS } from '@ultimat3/render';
|
|
14
15
|
import type { AxNode, ShotDriver, ShotPage } from './browser-launcher-port';
|
|
15
16
|
import { DEFAULT_PAGE_TIMEOUT_MS } from './cdp-shot-clock';
|
|
16
|
-
import {
|
|
17
|
+
import { runShot, SHOT_DIR, shotSlug } from './cmd-shot';
|
|
17
18
|
import type { ShotServer } from './shot-server';
|
|
18
19
|
import type { ShotVerdict } from './shot-verdict';
|
|
19
20
|
import type { InspectProbe } from './ui-inspect-probe';
|
|
@@ -144,7 +145,7 @@ export async function inspectRoute(
|
|
|
144
145
|
outDir,
|
|
145
146
|
driver,
|
|
146
147
|
boot: deps.boot,
|
|
147
|
-
settleMs:
|
|
148
|
+
settleMs: IDLE_HYDRATE_TIMEOUT_MS,
|
|
148
149
|
timeoutMs: DEFAULT_PAGE_TIMEOUT_MS,
|
|
149
150
|
fullPage: true,
|
|
150
151
|
colorScheme: input.colorScheme,
|
package/src/mcp-ui-interact.ts
CHANGED
|
@@ -14,9 +14,10 @@ import { join } from 'node:path';
|
|
|
14
14
|
import { isUltimateError, toUltimateError, UltimateError } from '@ultimat3/core';
|
|
15
15
|
import type { UiInteractInput, UiInteractResult, UiInteractStepResult } from '@ultimat3/mcp';
|
|
16
16
|
import { UI_INTERACT_LIMITS } from '@ultimat3/mcp';
|
|
17
|
+
import { IDLE_HYDRATE_TIMEOUT_MS } from '@ultimat3/render';
|
|
17
18
|
import type { ShotPage } from './browser-launcher-port';
|
|
18
19
|
import { DEFAULT_PAGE_TIMEOUT_MS } from './cdp-shot-clock';
|
|
19
|
-
import {
|
|
20
|
+
import { runShot, SHOT_DIR, shotSlug } from './cmd-shot';
|
|
20
21
|
import type { InspectDeps, Seen } from './mcp-ui-inspect';
|
|
21
22
|
import { readInspect, selectorsOf } from './mcp-ui-inspect';
|
|
22
23
|
import { SETTLE_POLL_MS } from './shot-settle';
|
|
@@ -265,7 +266,7 @@ export async function interactRoute(
|
|
|
265
266
|
outDir,
|
|
266
267
|
driver,
|
|
267
268
|
boot: deps.boot,
|
|
268
|
-
settleMs:
|
|
269
|
+
settleMs: IDLE_HYDRATE_TIMEOUT_MS,
|
|
269
270
|
timeoutMs: DEFAULT_PAGE_TIMEOUT_MS,
|
|
270
271
|
fullPage: input.fullPage,
|
|
271
272
|
colorScheme: input.colorScheme,
|
package/src/mcp-ui.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
// why: Bun exposes no path-join primitive, and the picture's directory is a path an agent opens.
|
|
12
12
|
import { join } from 'node:path';
|
|
13
|
-
import { UltimateError } from '@ultimat3/core';
|
|
13
|
+
import { finiteCount, UltimateError } from '@ultimat3/core';
|
|
14
14
|
import type {
|
|
15
15
|
UiDiffInput,
|
|
16
16
|
UiDiffResult,
|
|
@@ -23,11 +23,11 @@ import type {
|
|
|
23
23
|
UiShotInput,
|
|
24
24
|
UiShotResult,
|
|
25
25
|
} from '@ultimat3/mcp';
|
|
26
|
-
import {
|
|
26
|
+
import { describePages, IDLE_HYDRATE_TIMEOUT_MS } from '@ultimat3/render';
|
|
27
27
|
import { appBrowser } from './browser-launcher';
|
|
28
28
|
import type { ShotDriver } from './browser-launcher-port';
|
|
29
29
|
import { DEFAULT_PAGE_TIMEOUT_MS } from './cdp-shot-clock';
|
|
30
|
-
import {
|
|
30
|
+
import { readRoute, runShot, SHOT_DIR, shotSlug } from './cmd-shot';
|
|
31
31
|
import { islandShot } from './cmd-shot-island';
|
|
32
32
|
import { islandVerdictJson } from './island-verdict';
|
|
33
33
|
import { diffShots } from './mcp-ui-diff';
|
|
@@ -95,7 +95,7 @@ export interface UiHostInput {
|
|
|
95
95
|
readonly driver?: ((viewport: UiShotInput['viewport']) => Promise<ShotDriver>) | undefined;
|
|
96
96
|
/** Injected by a test: the route table, in place of the registry. */
|
|
97
97
|
readonly routes?: (() => readonly DeclaredRoute[]) | undefined;
|
|
98
|
-
/** Injected by a test: the settle wait, in place of `
|
|
98
|
+
/** Injected by a test: the settle wait, in place of `IDLE_HYDRATE_TIMEOUT_MS` (a real sleep per call). */
|
|
99
99
|
readonly settleMs?: number | undefined;
|
|
100
100
|
}
|
|
101
101
|
|
|
@@ -127,7 +127,7 @@ export function uiCapabilities(input: UiHostInput): UiCapabilities {
|
|
|
127
127
|
// The same choice `x shot` makes from the environment: `PUPPETEER_EXECUTABLE_PATH`, a
|
|
128
128
|
// provider's CDP URL, or the launcher's own discovery.
|
|
129
129
|
const browser = () => shotBrowserChoice({ cdpFlag: undefined, browserFlag: undefined, env });
|
|
130
|
-
const routes = input.routes ??
|
|
130
|
+
const routes = input.routes ?? describePages;
|
|
131
131
|
// One browser per call, sized to the call: the injected driver in a test, `appBrowser` otherwise.
|
|
132
132
|
const driverFor = async (viewport: UiShotInput['viewport']): Promise<ShotDriver> => {
|
|
133
133
|
if (input.driver !== undefined) return input.driver(viewport);
|
|
@@ -165,7 +165,8 @@ export function uiCapabilities(input: UiHostInput): UiCapabilities {
|
|
|
165
165
|
outDir,
|
|
166
166
|
driver,
|
|
167
167
|
boot,
|
|
168
|
-
|
|
168
|
+
// An agent's argument: a NaN or negative settle would wait forever or not at all.
|
|
169
|
+
settleMs: finiteCount('ui.shot', 'settleMs', input.settleMs ?? IDLE_HYDRATE_TIMEOUT_MS),
|
|
169
170
|
timeoutMs: DEFAULT_PAGE_TIMEOUT_MS,
|
|
170
171
|
fullPage: shot.fullPage,
|
|
171
172
|
colorScheme: shot.colorScheme,
|
|
@@ -199,7 +200,7 @@ export function uiCapabilities(input: UiHostInput): UiCapabilities {
|
|
|
199
200
|
root,
|
|
200
201
|
island: island.island,
|
|
201
202
|
...(island.state === undefined ? {} : { state: island.state }),
|
|
202
|
-
settleMs: input.settleMs ??
|
|
203
|
+
settleMs: finiteCount('ui.island', 'settleMs', input.settleMs ?? IDLE_HYDRATE_TIMEOUT_MS),
|
|
203
204
|
timeoutMs: DEFAULT_PAGE_TIMEOUT_MS,
|
|
204
205
|
...(executablePath === undefined ? {} : { executablePath }),
|
|
205
206
|
...(cdpUrl === undefined ? {} : { cdpUrl }),
|
package/src/measure-scope.ts
CHANGED
|
@@ -7,13 +7,8 @@
|
|
|
7
7
|
// `withInProcessFetch`, because no server is listening during a build (plan 101 slice 11 m).
|
|
8
8
|
|
|
9
9
|
import type { Actor, Ctx } from '@ultimat3/core';
|
|
10
|
-
import { measurementActor, runWithContext, withInProcessFetch } from '@ultimat3/core';
|
|
11
|
-
import {
|
|
12
|
-
createPipeline,
|
|
13
|
-
createRequestContext,
|
|
14
|
-
createRouter,
|
|
15
|
-
defineHttpConfig,
|
|
16
|
-
} from '@ultimat3/http';
|
|
10
|
+
import { actorLabel, measurementActor, runWithContext, withInProcessFetch } from '@ultimat3/core';
|
|
11
|
+
import { defineHttpConfig, httpPipeline, httpRouter, requestContext } from '@ultimat3/http';
|
|
17
12
|
import { apiRoutes } from './api-routes';
|
|
18
13
|
|
|
19
14
|
/** One build's measurement scope: its actor, and a runner that renders inside it. */
|
|
@@ -35,15 +30,15 @@ export async function measureScope(input: {
|
|
|
35
30
|
// No rate limit: every request here is this build's own render, and a limiter would need a
|
|
36
31
|
// deployment scope a build does not have (`X_RATE_LIMIT_SCOPE_UNSET`).
|
|
37
32
|
const config = defineHttpConfig({ buildId: input.buildId, rateLimit: { enabled: false } });
|
|
38
|
-
const ctx =
|
|
33
|
+
const ctx = requestContext({
|
|
39
34
|
url: new URL(input.origin),
|
|
40
35
|
method: 'GET',
|
|
41
36
|
role: 'web',
|
|
42
37
|
config,
|
|
43
38
|
});
|
|
44
39
|
ctx.actor = actor;
|
|
45
|
-
const pipeline =
|
|
46
|
-
table:
|
|
40
|
+
const pipeline = httpPipeline({
|
|
41
|
+
table: httpRouter(apiRoutes()),
|
|
47
42
|
config,
|
|
48
43
|
hooks: { authenticate: () => actor },
|
|
49
44
|
});
|
|
@@ -72,3 +67,13 @@ export async function withAppUrl<T>(origin: string, fn: () => Promise<T>): Promi
|
|
|
72
67
|
else process.env['APP_URL'] = before;
|
|
73
68
|
}
|
|
74
69
|
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The measurement actor as a refusal names it: core's `actorLabel` (kind, id, tenant) — the one
|
|
73
|
+
* actor format — plus the roles, which a role-gated page asks of it and `actorLabel` omits (#675).
|
|
74
|
+
* Never `facts`: this lands in a report and a gate finding.
|
|
75
|
+
*/
|
|
76
|
+
export function measurementActorLabel(actor: Actor): string {
|
|
77
|
+
const roles = actor.roles.length === 0 ? 'none' : actor.roles.join(', ');
|
|
78
|
+
return `${actorLabel(actor)} (roles: ${roles})`;
|
|
79
|
+
}
|