@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.
Files changed (296) hide show
  1. package/CLAUDE.md +28 -13
  2. package/README.md +32 -7
  3. package/package.json +29 -29
  4. package/src/admin-catalog-keys.ts +2 -2
  5. package/src/affected.ts +30 -36
  6. package/src/api-registration.ts +89 -0
  7. package/src/app-artifacts.ts +1 -1
  8. package/src/app-auth.ts +4 -27
  9. package/src/app-boundaries.ts +17 -4
  10. package/src/app-config-load.ts +51 -0
  11. package/src/app-env.ts +26 -25
  12. package/src/app-load.ts +38 -14
  13. package/src/app-manifest.ts +4 -10
  14. package/src/app-mcp.ts +31 -46
  15. package/src/app-name.ts +52 -0
  16. package/src/app-permissions-borrowed.ts +123 -0
  17. package/src/app-permissions-site.ts +48 -0
  18. package/src/app-permissions.ts +7 -17
  19. package/src/app-root.ts +9 -5
  20. package/src/bin.ts +3 -7
  21. package/src/browser-launcher.ts +4 -2
  22. package/src/budgets-charged.ts +60 -0
  23. package/src/budgets-scan.ts +76 -0
  24. package/src/budgets-unmeasured.ts +91 -0
  25. package/src/budgets.ts +117 -154
  26. package/src/cdp-shot-driver.ts +20 -1
  27. package/src/cdp-shot-watch.ts +35 -31
  28. package/src/ci-log.ts +0 -0
  29. package/src/cmd-auth-spec.ts +16 -0
  30. package/src/cmd-auth.ts +65 -0
  31. package/src/cmd-build-spec.ts +7 -1
  32. package/src/cmd-build.ts +151 -22
  33. package/src/cmd-db-backfill.ts +26 -13
  34. package/src/cmd-db-branch.ts +2 -2
  35. package/src/cmd-db.ts +10 -6
  36. package/src/cmd-deploy-compose.ts +94 -0
  37. package/src/cmd-deploy-drain.ts +48 -0
  38. package/src/cmd-deploy-helm.ts +7 -6
  39. package/src/cmd-deploy-start-first.ts +178 -0
  40. package/src/cmd-deploy.ts +158 -36
  41. package/src/cmd-dev.ts +18 -356
  42. package/src/cmd-doctor-spec.ts +2 -9
  43. package/src/cmd-doctor.ts +57 -80
  44. package/src/cmd-env.ts +55 -16
  45. package/src/cmd-generate.ts +89 -32
  46. package/src/cmd-help.ts +19 -6
  47. package/src/cmd-i18n.ts +21 -17
  48. package/src/cmd-jobs-spec.ts +7 -22
  49. package/src/cmd-jobs.ts +33 -140
  50. package/src/cmd-manifest-spec.ts +1 -1
  51. package/src/cmd-manifest.ts +6 -25
  52. package/src/cmd-mcp.ts +71 -22
  53. package/src/cmd-new.ts +36 -8
  54. package/src/cmd-planned.ts +24 -8
  55. package/src/cmd-routes.ts +15 -5
  56. package/src/cmd-secrets.ts +47 -6
  57. package/src/cmd-shot-matrix.ts +22 -7
  58. package/src/cmd-shot-spec.ts +10 -2
  59. package/src/cmd-shot.ts +46 -41
  60. package/src/cmd-tasks.ts +2 -2
  61. package/src/cmd-test-spec.ts +1 -7
  62. package/src/cmd-test.ts +14 -30
  63. package/src/cmd-verify.ts +4 -4
  64. package/src/coverage-lcov.ts +4 -2
  65. package/src/db-backfill.ts +2 -2
  66. package/src/db-replay-engine.ts +4 -10
  67. package/src/db-subscribes.ts +27 -16
  68. package/src/dev-boot.ts +290 -0
  69. package/src/dev-child-watch.ts +63 -12
  70. package/src/dev-dashboard-guard.ts +76 -0
  71. package/src/dev-dashboard.ts +56 -30
  72. package/src/dev-lock.ts +100 -31
  73. package/src/dev-rebuild.ts +80 -0
  74. package/src/dev-restart-retire.ts +46 -0
  75. package/src/dev-route-table.ts +30 -8
  76. package/src/dev-supervisor.ts +114 -22
  77. package/src/dev-traces.ts +53 -20
  78. package/src/dispatch.ts +17 -13
  79. package/src/doctor-app-url.ts +39 -0
  80. package/src/doctor-auth.ts +95 -0
  81. package/src/doctor-offline.ts +6 -5
  82. package/src/document-options.ts +7 -0
  83. package/src/drift-replica-identity.ts +42 -0
  84. package/src/drift.ts +29 -6
  85. package/src/error-catalog.ts +6 -9
  86. package/src/error-codes.ts +29 -3
  87. package/src/error-page-csp.ts +14 -15
  88. package/src/error-unthrown.ts +29 -11
  89. package/src/errors.ts +54 -83
  90. package/src/exec.ts +8 -0
  91. package/src/favicon.ts +2 -2
  92. package/src/fix-command.ts +9 -1
  93. package/src/framework-env.ts +148 -0
  94. package/src/framework-schema-apply.ts +78 -0
  95. package/src/framework-schema-stamp.ts +96 -0
  96. package/src/framework-schema.ts +25 -3
  97. package/src/generate-catalog-locales.ts +79 -0
  98. package/src/generate-files.ts +4 -3
  99. package/src/generate-kinds.ts +18 -0
  100. package/src/generate-plural.ts +50 -0
  101. package/src/generate-shadow.ts +95 -0
  102. package/src/generate-write.ts +47 -13
  103. package/src/gh-target.ts +12 -7
  104. package/src/hold.ts +56 -1
  105. package/src/i18n-audit.ts +25 -9
  106. package/src/i18n-index.ts +5 -6
  107. package/src/i18n-registration.ts +7 -9
  108. package/src/import-scan.ts +28 -3
  109. package/src/index.ts +2 -6
  110. package/src/island-bundle.ts +57 -79
  111. package/src/island-capture.ts +1 -1
  112. package/src/island-duplicate-refusal.ts +57 -0
  113. package/src/island-duplicates.ts +221 -0
  114. package/src/island-entry-missing.ts +64 -0
  115. package/src/island-harness-route.ts +20 -4
  116. package/src/island-harness-script.ts +23 -2
  117. package/src/island-identity.ts +17 -14
  118. package/src/island-package-dedupe.ts +150 -0
  119. package/src/island-realtime.ts +36 -16
  120. package/src/island-routes.ts +2 -2
  121. package/src/island-runtime.ts +241 -0
  122. package/src/island-sources.ts +105 -0
  123. package/src/island-states-load.ts +6 -0
  124. package/src/island-store.ts +90 -6
  125. package/src/jobs-json.ts +0 -26
  126. package/src/jobs-report.ts +36 -20
  127. package/src/local-cli-handoff.ts +90 -0
  128. package/src/loopback-host.ts +13 -0
  129. package/src/manifest-staleness.ts +62 -0
  130. package/src/mcp-errors.ts +20 -4
  131. package/src/mcp-host.ts +22 -14
  132. package/src/mcp-test-run.ts +38 -0
  133. package/src/mcp-ui-inspect.ts +3 -2
  134. package/src/mcp-ui-interact.ts +3 -2
  135. package/src/mcp-ui.ts +8 -7
  136. package/src/measure-scope.ts +15 -10
  137. package/src/messages.ts +29 -16
  138. package/src/metrics-endpoint.ts +28 -3
  139. package/src/migration-extensions.ts +1 -1
  140. package/src/otlp-export.ts +2 -2
  141. package/src/output.ts +24 -0
  142. package/src/page-navigation.ts +7 -23
  143. package/src/page-speculation.ts +19 -31
  144. package/src/parse.ts +0 -8
  145. package/src/permission-grants.ts +3 -3
  146. package/src/posix-path.ts +30 -0
  147. package/src/prerender-locales.ts +6 -4
  148. package/src/prerender-out.ts +34 -7
  149. package/src/prerender-report.ts +58 -0
  150. package/src/prerender-served.ts +52 -0
  151. package/src/prerender.ts +71 -103
  152. package/src/pwa-artifacts.ts +33 -94
  153. package/src/pwa-manifest-locales.ts +9 -9
  154. package/src/registry.ts +2 -0
  155. package/src/retry-memo.ts +1 -1
  156. package/src/revalidated-response.ts +4 -3
  157. package/src/role-realtime.ts +7 -1
  158. package/src/role-replicator.ts +124 -29
  159. package/src/role-start.ts +69 -85
  160. package/src/role-sync.ts +119 -25
  161. package/src/root-env.ts +157 -28
  162. package/src/route-islands.ts +69 -0
  163. package/src/runtime-admin.ts +4 -1
  164. package/src/runtime-assets.ts +26 -9
  165. package/src/runtime-bindings.ts +27 -7
  166. package/src/runtime-cache.ts +46 -55
  167. package/src/runtime-hooks.ts +2 -1
  168. package/src/runtime-idempotency-scope.ts +23 -0
  169. package/src/runtime-isr-outcome.ts +100 -0
  170. package/src/runtime-isr.ts +36 -0
  171. package/src/runtime-jobs.ts +76 -41
  172. package/src/runtime-live-feed.ts +1 -1
  173. package/src/runtime-mail.ts +26 -0
  174. package/src/runtime-mfa-warning.ts +29 -0
  175. package/src/runtime-notify-retention.ts +10 -46
  176. package/src/runtime-overrides.ts +4 -4
  177. package/src/runtime-purge.ts +38 -16
  178. package/src/runtime-queue.ts +43 -22
  179. package/src/runtime-realtime.ts +11 -45
  180. package/src/runtime-render.ts +136 -114
  181. package/src/runtime-replica.ts +3 -8
  182. package/src/runtime-services.ts +49 -26
  183. package/src/runtime-storage-upload.ts +2 -2
  184. package/src/runtime-storage.ts +13 -8
  185. package/src/schema-diff.ts +16 -0
  186. package/src/schema-drift.ts +45 -15
  187. package/src/secrets-rotation.ts +8 -9
  188. package/src/seo-routes.ts +80 -9
  189. package/src/serve-boot.ts +51 -12
  190. package/src/serve-drain.ts +11 -33
  191. package/src/serve-env.ts +9 -6
  192. package/src/serve-retire.ts +95 -0
  193. package/src/serve-web.ts +31 -15
  194. package/src/serve.ts +27 -10
  195. package/src/server-barrels.ts +6 -3
  196. package/src/shot-browser.ts +1 -1
  197. package/src/shot-locale.ts +6 -33
  198. package/src/shot-settle.ts +3 -3
  199. package/src/shot-theme.ts +1 -1
  200. package/src/shot-viewport.ts +152 -0
  201. package/src/signal-shred.ts +26 -12
  202. package/src/site-asset-routes.ts +2 -2
  203. package/src/site-assets.ts +3 -5
  204. package/src/site-config.ts +9 -48
  205. package/src/site-seo.ts +7 -5
  206. package/src/sitemap-lastmod.ts +5 -0
  207. package/src/solid-loader.ts +12 -3
  208. package/src/source-files.ts +19 -3
  209. package/src/static-report.ts +5 -0
  210. package/src/stored-object-headers.ts +43 -0
  211. package/src/style-routes.ts +2 -2
  212. package/src/sw-artifacts.ts +8 -8
  213. package/src/sw-precache-plan.ts +34 -4
  214. package/src/sync-authenticator.ts +3 -3
  215. package/src/sync-url.ts +3 -3
  216. package/src/tasks-facts.ts +3 -29
  217. package/src/templates/action.ts +7 -4
  218. package/src/templates/backfill.ts +4 -4
  219. package/src/templates/entity.ts +2 -2
  220. package/src/templates/github/ci.yml.ts +28 -18
  221. package/src/templates/github/image.yml.ts +92 -0
  222. package/src/templates/github/runner.ts +18 -0
  223. package/src/templates/guard-bare-error.ts +16 -2
  224. package/src/templates/guard-unzoned-date.ts +56 -6
  225. package/src/templates/index.ts +1 -1
  226. package/src/templates/job-body-test.ts +2 -2
  227. package/src/templates/job.ts +6 -6
  228. package/src/templates/naming.ts +28 -0
  229. package/src/templates/query.ts +9 -8
  230. package/src/templates/resource-page.ts +9 -7
  231. package/src/templates/resource-service.ts +2 -2
  232. package/src/templates/route.ts +26 -14
  233. package/src/templates/scaffold-api.ts +2 -3
  234. package/src/templates/scaffold-app.ts +8 -3
  235. package/src/templates/scaffold-auth.ts +205 -70
  236. package/src/templates/scaffold-bin.ts +122 -0
  237. package/src/templates/scaffold-claude-commands.ts +11 -11
  238. package/src/templates/scaffold-container-compose.ts +10 -1
  239. package/src/templates/scaffold-container.ts +16 -8
  240. package/src/templates/scaffold-dashboard-bare.ts +8 -4
  241. package/src/templates/scaffold-dashboard-example-tests.ts +208 -0
  242. package/src/templates/scaffold-dashboard-example.ts +112 -172
  243. package/src/templates/scaffold-dashboard-shared.ts +62 -1
  244. package/src/templates/scaffold-dashboard.ts +9 -5
  245. package/src/templates/scaffold-db-client.ts +4 -2
  246. package/src/templates/scaffold-db-package.ts +2 -2
  247. package/src/templates/scaffold-docs.ts +20 -77
  248. package/src/templates/scaffold-domain-package.ts +0 -4
  249. package/src/templates/scaffold-entries.ts +4 -3
  250. package/src/templates/scaffold-env.ts +6 -3
  251. package/src/templates/scaffold-gitattributes.ts +59 -0
  252. package/src/templates/scaffold-helm-fences.ts +76 -0
  253. package/src/templates/scaffold-helm-templates.ts +100 -19
  254. package/src/templates/scaffold-helm.ts +62 -5
  255. package/src/templates/scaffold-http.ts +1 -1
  256. package/src/templates/scaffold-i18n.ts +15 -2
  257. package/src/templates/scaffold-icon.ts +2 -2
  258. package/src/templates/scaffold-repo.ts +27 -16
  259. package/src/templates/scaffold-shell.ts +55 -37
  260. package/src/test-counts.ts +2 -6
  261. package/src/test-ignores.ts +25 -0
  262. package/src/test-only-shipped.ts +193 -0
  263. package/src/test-passes.ts +0 -15
  264. package/src/test-select.ts +7 -15
  265. package/src/test-shards.ts +40 -95
  266. package/src/test-workers.ts +26 -4
  267. package/src/theme-boot.ts +8 -33
  268. package/src/theme-brand.ts +66 -0
  269. package/src/trusted-hops-error.ts +17 -0
  270. package/src/trusted-hops.ts +49 -0
  271. package/src/unscanned-admin.ts +2 -2
  272. package/src/unthrown-codes.ts +26 -0
  273. package/src/verify-checks.ts +29 -48
  274. package/src/verify-coverage-run.ts +3 -2
  275. package/src/verify-deadline.ts +207 -33
  276. package/src/verify-merge.ts +108 -43
  277. package/src/verify-part-step.ts +98 -0
  278. package/src/verify-role-load-probe.ts +12 -2
  279. package/src/verify-role-load.ts +9 -4
  280. package/src/verify-run.ts +32 -8
  281. package/src/verify-shard.ts +13 -2
  282. package/src/verify-stalled.ts +152 -0
  283. package/src/verify-step.ts +2 -0
  284. package/src/verify-test-run.ts +3 -2
  285. package/src/verify-tests.ts +5 -3
  286. package/src/worker-bundle.ts +2 -2
  287. package/src/workspace-checks.ts +53 -7
  288. package/src/workspace-graph.ts +3 -2
  289. package/src/write-line.ts +82 -7
  290. package/src/browser-launcher-fake-html.ts +0 -80
  291. package/src/browser-launcher-fake.ts +0 -168
  292. package/src/build-errors.ts +0 -54
  293. package/src/jobs-drain.ts +0 -163
  294. package/src/scaffold-typecheck.ts +0 -227
  295. package/src/thrown-by.ts +0 -24
  296. package/src/verify-errors.ts +0 -58
@@ -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 `createMemoryDriver()`
3
- // alone. Drain is `jobs-drain.ts`, the `--json` shapes `jobs-json.ts`, the table `jobs-table.ts`.
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
- export interface JobsListResult {
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, rows, deadLetters, backfills] = await Promise.all([
126
+ const [depth, page, deadLetters, backfills] = await Promise.all([
111
127
  inspectQueues(driver),
112
- inspectJobList(driver, jobFilter),
128
+ jobPage(driver, jobFilter),
113
129
  inspectDeadLetters(driver),
114
130
  inspectBackfills(driver, { status: 'running' }),
115
131
  ]);
116
- return { depth, rows, deadLetters, backfills };
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`: the
131
- * concrete drivers (pg, memory) throw their own error from inside `requeue()` for a missing
132
- * row, and that error is not this command's contract — `retryFromStep`'s documented `undefined`
133
- * return is, and the driver never reaches it once the row is already known absent.
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
- X_TEST_SHARD_FAILED: 'x test --workers 1 --json',
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: 'export CHROME_PATH=/usr/bin/google-chrome',
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 { agentActor, isUltimateError, renderThrowable, UltimateError } from '@ultimat3/core';
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 { createDevServer, DEV_SCOPES, devHost, frameworkIntrospection } from '@ultimat3/mcp';
32
- import { describeRoutes } from '@ultimat3/render';
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, CliNotImplementedError } from './errors';
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 { parseBunTest } from './mcp-test-output';
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 CliNotImplementedError({
111
- feature: 'an MCP tool that needs the database after the host closed',
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
- return parseBunTest(execOutput(result), result.durationMs);
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 CliNotImplementedError({
285
- feature: `logs.tail without ${path}`,
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: () => describeRoutes(),
352
+ routes: () => describePages(),
345
353
  policies: () => policyFacts(),
346
354
  });
347
- const server = createDevServer({ host: devHost(introspection, capabilities(input, lazy, ui)) });
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
+ }
@@ -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 { DEFAULT_SETTLE_MS, runShot, SHOT_DIR, shotSlug } from './cmd-shot';
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: DEFAULT_SETTLE_MS,
148
+ settleMs: IDLE_HYDRATE_TIMEOUT_MS,
148
149
  timeoutMs: DEFAULT_PAGE_TIMEOUT_MS,
149
150
  fullPage: true,
150
151
  colorScheme: input.colorScheme,
@@ -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 { DEFAULT_SETTLE_MS, runShot, SHOT_DIR, shotSlug } from './cmd-shot';
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: DEFAULT_SETTLE_MS,
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 { describeRoutes } from '@ultimat3/render';
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 { DEFAULT_SETTLE_MS, readRoute, runShot, SHOT_DIR, shotSlug } from './cmd-shot';
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 `DEFAULT_SETTLE_MS` (a real sleep per call). */
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 ?? describeRoutes;
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
- settleMs: input.settleMs ?? DEFAULT_SETTLE_MS,
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 ?? DEFAULT_SETTLE_MS,
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 }),
@@ -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 = createRequestContext({
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 = createPipeline({
46
- table: createRouter(apiRoutes()),
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
+ }