@frontera-sdk/cli 1.52.38 → 1.52.40

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 (225) hide show
  1. package/README.md +9 -0
  2. package/package.json +6 -4
  3. package/src/adopt.ts +6 -2
  4. package/src/api/apps-api.ts +86 -4
  5. package/src/api/blueprint-authoring-api.ts +35 -11
  6. package/src/api/dataset-api.ts +29 -0
  7. package/src/api/governed-action-api.ts +90 -2
  8. package/src/api/platform-api.ts +87 -2
  9. package/src/api/workflow-api.ts +33 -2
  10. package/src/app-contract.ts +156 -0
  11. package/src/app-lock.ts +125 -0
  12. package/src/args.ts +14 -3
  13. package/src/auth-verify.ts +10 -0
  14. package/src/automation-template.ts +12 -1
  15. package/src/blueprint/action-rules.ts +516 -0
  16. package/src/blueprint/action-semantic-fit.ts +205 -0
  17. package/src/blueprint/compile.ts +51 -2
  18. package/src/blueprint/diff.ts +34 -2
  19. package/src/blueprint/metric-query.ts +215 -0
  20. package/src/blueprint/metric-rules.ts +122 -0
  21. package/src/blueprint/object-sets.ts +116 -0
  22. package/src/blueprint/ontology-edit-plan.ts +48 -4
  23. package/src/blueprint/projection.ts +24 -1
  24. package/src/blueprint/scaffold.ts +84 -42
  25. package/src/commands/action/available.ts +1 -1
  26. package/src/commands/action/criteria-refusal.ts +84 -0
  27. package/src/commands/action/decide.ts +5 -2
  28. package/src/commands/action/deploy.ts +13 -4
  29. package/src/commands/action/list.ts +134 -19
  30. package/src/commands/action/requests.ts +34 -6
  31. package/src/commands/action/submit.ts +28 -13
  32. package/src/commands/agent/index-commands.ts +69 -7
  33. package/src/commands/agent/resolve.ts +2 -0
  34. package/src/commands/app/checks.ts +44 -0
  35. package/src/commands/app/deploy.ts +243 -60
  36. package/src/commands/app/deployments.ts +97 -0
  37. package/src/commands/app/dev.ts +103 -11
  38. package/src/commands/app/init.ts +36 -3
  39. package/src/commands/app/promote.ts +45 -19
  40. package/src/commands/app/pull.ts +3 -1
  41. package/src/commands/app/rollback.ts +46 -0
  42. package/src/commands/app/sdk.ts +54 -8
  43. package/src/commands/app/unpin.ts +27 -0
  44. package/src/commands/app/versions.ts +1 -1
  45. package/src/commands/automation/bindings.ts +4 -2
  46. package/src/commands/automation/build-entry.ts +4 -1
  47. package/src/commands/automation/index-commands.ts +3 -1
  48. package/src/commands/automation/pull.ts +6 -1
  49. package/src/commands/automation/run.ts +34 -9
  50. package/src/commands/blueprint/bind.ts +2 -1
  51. package/src/commands/blueprint/declarative.ts +331 -31
  52. package/src/commands/blueprint/get.ts +6 -2
  53. package/src/commands/blueprint/grants.ts +64 -10
  54. package/src/commands/blueprint/metric.ts +108 -0
  55. package/src/commands/blueprint/object-sets.ts +71 -0
  56. package/src/commands/blueprint/query.ts +155 -31
  57. package/src/commands/dataset/index-commands.ts +156 -7
  58. package/src/commands/knowledge/index-commands.ts +122 -20
  59. package/src/commands/local/check.ts +657 -44
  60. package/src/commands/local/data.ts +33 -12
  61. package/src/commands/local/dev.ts +875 -88
  62. package/src/commands/local/docs-mcp.ts +10 -18
  63. package/src/commands/local/index-commands.ts +300 -63
  64. package/src/commands/local/init.ts +18 -5
  65. package/src/commands/local/key.ts +0 -3
  66. package/src/commands/local/providers.ts +0 -7
  67. package/src/commands/local/remove.ts +116 -0
  68. package/src/commands/local/schema.ts +3 -4
  69. package/src/commands/local/sdk-refresh.ts +59 -0
  70. package/src/commands/local/snapshot.ts +100 -0
  71. package/src/commands/local/sync-request.ts +320 -13
  72. package/src/commands/local/sync.ts +102 -5
  73. package/src/commands/mcp/serve.ts +34 -0
  74. package/src/commands/registry.ts +52 -8
  75. package/src/commands/types.ts +29 -0
  76. package/src/commands/workflow/archive.ts +46 -0
  77. package/src/commands/workflow/get.ts +2 -1
  78. package/src/commands/workflow/index-commands.ts +5 -0
  79. package/src/commands/workflow/push.ts +49 -2
  80. package/src/commands/workflow/run.ts +65 -7
  81. package/src/commands/workflow/runs.ts +9 -6
  82. package/src/commands/workflow/webhook-test.ts +166 -0
  83. package/src/config.ts +43 -6
  84. package/src/dev-broker.ts +52 -0
  85. package/src/exit.ts +103 -4
  86. package/src/flag-help.ts +77 -17
  87. package/src/forge/touches.ts +15 -12
  88. package/src/function-outside-imports.ts +89 -0
  89. package/src/harness.ts +42 -24
  90. package/src/heal.ts +112 -1
  91. package/src/local/fix-words.ts +12 -0
  92. package/src/local/function-bundle-recovery.ts +775 -0
  93. package/src/local/local-actions.ts +103 -10
  94. package/src/local/local-agent-action-grants.ts +210 -0
  95. package/src/local/local-agent-checks.ts +232 -0
  96. package/src/local/local-agent-grants.ts +29 -7
  97. package/src/local/local-agent-lean-plugins.ts +68 -0
  98. package/src/local/local-agent-missing-tools.ts +68 -0
  99. package/src/local/local-agent-name.ts +27 -0
  100. package/src/local/local-app-code.ts +191 -0
  101. package/src/local/local-app-dev-files.ts +186 -0
  102. package/src/local/local-app-dev-session.ts +88 -0
  103. package/src/local/local-app-lock-install.ts +114 -0
  104. package/src/local/local-app-pulled.ts +167 -0
  105. package/src/local/local-app-restore.ts +111 -0
  106. package/src/local/local-app-runtime.ts +68 -0
  107. package/src/local/local-app-sdk-paths.ts +149 -0
  108. package/src/local/local-app-sdk.ts +509 -0
  109. package/src/local/local-applied.ts +211 -21
  110. package/src/local/local-apply-history.ts +84 -0
  111. package/src/local/local-apply-summary.ts +86 -0
  112. package/src/local/local-apply-watchdog.ts +95 -0
  113. package/src/local/local-apps.ts +180 -7
  114. package/src/local/local-blueprint-errors.ts +198 -0
  115. package/src/local/local-blueprint-outline.ts +31 -12
  116. package/src/local/local-blueprint-schemas.ts +582 -0
  117. package/src/local/local-build-inputs.ts +48 -0
  118. package/src/local/local-build-summary.ts +495 -28
  119. package/src/local/local-case-evidence.ts +120 -0
  120. package/src/local/local-case-not-run.ts +153 -0
  121. package/src/local/local-chat-apps.ts +93 -16
  122. package/src/local/local-check-inherited.ts +83 -0
  123. package/src/local/local-cities.ts +89 -0
  124. package/src/local/local-component-health.ts +119 -0
  125. package/src/local/local-control.ts +34 -7
  126. package/src/local/local-db-snapshot.ts +348 -0
  127. package/src/local/local-dev-references.ts +346 -35
  128. package/src/local/local-dev-sync.ts +136 -7
  129. package/src/local/local-docker.ts +35 -1
  130. package/src/local/local-docs-search.ts +1 -1
  131. package/src/local/local-docs.ts +281 -2
  132. package/src/local/local-empty-workflows.ts +92 -0
  133. package/src/local/local-env.ts +16 -1
  134. package/src/local/local-file-checks.ts +104 -0
  135. package/src/local/local-function-ids.ts +141 -0
  136. package/src/local/local-function-recovery.ts +141 -0
  137. package/src/local/local-function-state.ts +87 -0
  138. package/src/local/local-keys.ts +7 -5
  139. package/src/local/local-knowledge-ingestion.ts +225 -0
  140. package/src/local/local-knowledge-uploads.ts +217 -0
  141. package/src/local/local-media-placeholders.ts +182 -0
  142. package/src/local/local-media-sets.ts +90 -10
  143. package/src/local/local-model-parameters.ts +89 -0
  144. package/src/local/local-object-sets.ts +18 -6
  145. package/src/local/local-operating.ts +35 -1
  146. package/src/local/local-org-settings.ts +205 -7
  147. package/src/local/local-orphans.ts +321 -0
  148. package/src/local/local-personas.ts +296 -0
  149. package/src/local/local-portability.ts +141 -16
  150. package/src/local/local-ports.ts +99 -7
  151. package/src/local/local-project-notices.ts +234 -0
  152. package/src/local/local-project-secrets.ts +133 -0
  153. package/src/local/local-project.ts +12 -0
  154. package/src/local/local-provider-refusal.ts +66 -0
  155. package/src/local/local-request-evidence.ts +112 -0
  156. package/src/local/local-runtime-health.ts +123 -0
  157. package/src/local/local-runtime-history.ts +50 -0
  158. package/src/local/local-runtime-target.ts +42 -0
  159. package/src/local/local-sample-data.ts +141 -0
  160. package/src/local/local-sample-remake.ts +138 -0
  161. package/src/local/local-scaffold.ts +336 -53
  162. package/src/local/local-schema-check.ts +148 -0
  163. package/src/local/local-schemas.ts +742 -25
  164. package/src/local/local-secret-carry.ts +107 -0
  165. package/src/local/local-secrets.ts +38 -8
  166. package/src/local/local-seed.ts +102 -26
  167. package/src/local/local-stand-ins.ts +1 -1
  168. package/src/local/local-state.ts +43 -2
  169. package/src/local/local-studio-server.ts +170 -7
  170. package/src/local/local-supervisor.ts +44 -6
  171. package/src/local/local-sync-names.ts +2 -0
  172. package/src/local/local-sync-plan.ts +11 -0
  173. package/src/local/local-sync-push.ts +10 -7
  174. package/src/local/local-sync-refs.ts +49 -3
  175. package/src/local/local-sync-remote.ts +343 -32
  176. package/src/local/local-sync-snapshot.ts +15 -10
  177. package/src/local/local-synthetic-data.ts +2116 -99
  178. package/src/local/local-test-baseline.ts +113 -0
  179. package/src/local/local-test-cases.ts +76 -1
  180. package/src/local/local-test-runner.ts +582 -91
  181. package/src/local/local-test-runs.ts +13 -5
  182. package/src/local/local-vendored-sdk.ts +183 -0
  183. package/src/local/local-workflow-state.ts +16 -3
  184. package/src/local/local-workflow-tests.ts +482 -0
  185. package/src/local/local-workspace-grants.ts +81 -0
  186. package/src/main.ts +50 -9
  187. package/src/mcp/a11y-scan.ts +55 -0
  188. package/src/mcp/browser/ax-snapshot.ts +181 -0
  189. package/src/mcp/browser/credentials.ts +78 -0
  190. package/src/mcp/browser/frames.ts +336 -0
  191. package/src/mcp/browser/headless-session.ts +413 -0
  192. package/src/mcp/browser/headless-tab.ts +575 -0
  193. package/src/mcp/browser/keys.ts +70 -0
  194. package/src/mcp/browser/page-scripts.ts +129 -0
  195. package/src/mcp/browser/wait.ts +73 -0
  196. package/src/mcp/forge-link.ts +126 -0
  197. package/src/mcp/headless.ts +199 -0
  198. package/src/mcp/mcp-apps.ts +435 -0
  199. package/src/mcp/mcp-chat.ts +226 -0
  200. package/src/mcp/mcp-cli.ts +53 -0
  201. package/src/mcp/mcp-protocol.ts +122 -0
  202. package/src/mcp/mcp-runtime.ts +113 -0
  203. package/src/mcp/mcp-server.ts +108 -0
  204. package/src/mcp/mcp-traces.ts +409 -0
  205. package/src/mcp/page-observation.ts +193 -0
  206. package/src/mcp/tools/agent-tools.ts +700 -0
  207. package/src/mcp/tools/app-tools.ts +241 -0
  208. package/src/mcp/tools/browser-tools.ts +310 -0
  209. package/src/mcp/tools/docs-tools.ts +32 -0
  210. package/src/mcp/tools/project-tools.ts +205 -0
  211. package/src/mcp/tools/test-tools.ts +339 -0
  212. package/src/mcp/tools/workflow-tools.ts +611 -0
  213. package/src/output.ts +1 -1
  214. package/src/pack.ts +8 -3
  215. package/src/packaging.ts +1 -1
  216. package/src/project-bootstrap.ts +107 -4
  217. package/src/project-context.ts +18 -1
  218. package/src/project.ts +27 -2
  219. package/src/provenance.ts +25 -0
  220. package/src/render-evidence.ts +65 -7
  221. package/src/template.ts +14 -8
  222. package/src/templates/next-app-files.ts +35 -6
  223. package/src/templates/next-skills.ts +61 -27
  224. package/src/vendor/kit-assets.json +6 -6
  225. package/src/vendor/sdk-sources.json +24 -21
@@ -0,0 +1,120 @@
1
+ import { runtimeLink, studioLink, type RuntimeLink } from '../mcp/mcp-runtime'
2
+ import { conversationTraces, traceLink } from '../mcp/mcp-traces'
3
+ import type { LocalProject } from './local-project'
4
+ import type { CaseResult } from './local-test-runner'
5
+
6
+ /**
7
+ * What a failing Agent case leaves to look at: its conversation, the trace of
8
+ * its last turn and the start of the reply it was judged on, so whoever reads
9
+ * `local check` or `local test` (a coding agent with `--json`) can tell an
10
+ * Agent regression from a check that asks for the wrong thing without
11
+ * matching traces by their time.
12
+ *
13
+ * Traces are kept by the local traces store, outside the platform database:
14
+ * `local check` rolls back the database after its tests (the conversation goes
15
+ * with it), and the trace stays readable (`trace_get`, Studio's Traces page).
16
+ */
17
+ export interface CaseEvidence {
18
+ conversationId?: string
19
+ traceId?: string
20
+ /** Studio's page for the trace. */
21
+ traceLink?: string
22
+ /** Studio's page for the conversation, while the database still holds it. */
23
+ conversationLink?: string
24
+ /** The reply the case was judged on, its start (`REPLY_EXCERPT` characters). */
25
+ replyExcerpt?: string
26
+ }
27
+
28
+ export const REPLY_EXCERPT = 300
29
+
30
+ export function replyExcerpt(reply: string): string | undefined {
31
+ const text = reply.replace(/\s+/g, ' ').trim()
32
+ if (!text) return undefined
33
+ return text.length > REPLY_EXCERPT ? `${text.slice(0, REPLY_EXCERPT)}…` : text
34
+ }
35
+
36
+ /** The newest trace of the conversation that called a model (the turn the reply came from), else its newest. */
37
+ async function lastTurnTrace(link: RuntimeLink, conversationId: string, since?: string): Promise<string | null> {
38
+ const traces = (await conversationTraces(link, conversationId, since)).sort((a, b) => Date.parse(b.timestamp) - Date.parse(a.timestamp))
39
+ return (traces.find((t) => t.observations.some((o) => o.type === 'GENERATION')) ?? traces[0])?.id ?? null
40
+ }
41
+
42
+ /**
43
+ * The evidence of each failing case (never one that passed or couldn't run),
44
+ * by its index in `results`. A trace that has not arrived is looked for again
45
+ * for a few seconds; one that never does leaves its id out, never the case.
46
+ */
47
+ export async function failingCaseEvidence(project: LocalProject, results: readonly CaseResult[], opts: { waitMs?: number } = {}): Promise<Map<number, CaseEvidence>> {
48
+ const out = new Map<number, CaseEvidence>()
49
+ const failing = results.map((r, i) => [r, i] as const).filter(([r]) => !r.passed && !r.notRun)
50
+ if (!failing.length) return out
51
+ const link = runtimeLink(project)
52
+ const deadline = Date.now() + (opts.waitMs ?? 4_000)
53
+ for (const [r, i] of failing) {
54
+ const evidence: CaseEvidence = {
55
+ ...(r.conversationId ? { conversationId: r.conversationId } : {}),
56
+ ...(replyExcerpt(r.reply) ? { replyExcerpt: replyExcerpt(r.reply) } : {}),
57
+ }
58
+ if (typeof link !== 'string' && r.conversationId) {
59
+ if (r.agentId) evidence.conversationLink = studioLink(link, `/agents/${encodeURIComponent(r.agentId)}/c/${encodeURIComponent(r.conversationId)}`)
60
+ const since = r.startedAt ? new Date(Date.parse(r.startedAt) - 1_000).toISOString() : undefined
61
+ for (;;) {
62
+ const id = await lastTurnTrace(link, r.conversationId, since).catch(() => null)
63
+ if (id) {
64
+ evidence.traceId = id
65
+ evidence.traceLink = traceLink(link, id)
66
+ break
67
+ }
68
+ if (Date.now() >= deadline) break
69
+ await new Promise((res) => setTimeout(res, 500))
70
+ }
71
+ }
72
+ if (Object.keys(evidence).length) out.set(i, evidence)
73
+ }
74
+ return out
75
+ }
76
+
77
+ /**
78
+ * The trace of each case's last turn, by its index in `results`: every case
79
+ * that has a conversation (passed, failed, or one that could not finish), so
80
+ * `local test --json` and the MCP `test_run` name the trace beside the
81
+ * conversation, and nobody matches traces by their time. A trace that has not
82
+ * arrived is looked for again for a few seconds, across the cases together
83
+ * (a second only, when none of the run's has arrived); one that never does
84
+ * leaves its case without a `traceId`. `known`: the
85
+ * failing cases' evidence already found, not looked for again. A case of several
86
+ * turns names its last turn's trace; `trace_list` with the conversation id
87
+ * lists the others.
88
+ */
89
+ export async function caseTraces(project: LocalProject, results: readonly CaseResult[], opts: { waitMs?: number; known?: ReadonlyMap<number, CaseEvidence> } = {}): Promise<Map<number, { traceId: string; traceLink: string }>> {
90
+ const out = new Map<number, { traceId: string; traceLink: string }>()
91
+ const link = runtimeLink(project)
92
+ if (typeof link === 'string') return out
93
+ for (const [i, e] of opts.known ?? []) if (e.traceId && e.traceLink) out.set(i, { traceId: e.traceId, traceLink: e.traceLink })
94
+ let missing = results.map((r, i) => [r, i] as const).filter(([r, i]) => !!r.conversationId && !out.has(i))
95
+ const started = Date.now()
96
+ const deadline = started + (opts.waitMs ?? 4_000)
97
+ while (missing.length) {
98
+ for (const [r, i] of missing) {
99
+ const since = r.startedAt ? new Date(Date.parse(r.startedAt) - 1_000).toISOString() : undefined
100
+ const id = await lastTurnTrace(link, r.conversationId!, since).catch(() => null)
101
+ if (id) out.set(i, { traceId: id, traceLink: traceLink(link, id) })
102
+ }
103
+ missing = missing.filter(([, i]) => !out.has(i))
104
+ // A store that has none of this run's traces after a second is not about to: no wait for the rest.
105
+ if (!missing.length || Date.now() >= deadline || (!out.size && Date.now() >= started + 1_000)) break
106
+ await new Promise((res) => setTimeout(res, 500))
107
+ }
108
+ return out
109
+ }
110
+
111
+ /** One line under a failing case: "trace 6f5e… (link); conversation c-1; replied: "…"". */
112
+ export function evidenceLine(e: CaseEvidence, opts: { rolledBack?: boolean } = {}): string {
113
+ return [
114
+ e.traceId ? `trace ${e.traceId}${e.traceLink ? ` (${e.traceLink})` : ''}` : e.conversationId ? 'trace not arrived yet (trace_list with the conversation id finds it)' : '',
115
+ e.conversationId ? `conversation ${e.conversationId}${opts.rolledBack ? ' (rolled back with the test data; the trace stays)' : ''}` : '',
116
+ e.replyExcerpt ? `replied: "${e.replyExcerpt}"` : 'no reply',
117
+ ]
118
+ .filter(Boolean)
119
+ .join('; ')
120
+ }
@@ -0,0 +1,153 @@
1
+ /**
2
+ * An Agent case that couldn't run, told apart from one that failed.
3
+ *
4
+ * A case fails when the Agent's reply or tools miss what the case expects:
5
+ * that is what a change broke. A case that never got a reply because the
6
+ * model provider refused the request (out of credit, a rate limit, a refused
7
+ * key, an outage), the turn timed out, or the local runtime did not answer
8
+ * says nothing about the Agent. Such a case still keeps the gate from
9
+ * passing, but it is never counted as newly failing, never recorded as the
10
+ * case's last outcome, and it is said with its cause and the fix.
11
+ *
12
+ * The runtime describes a provider failure as `<status> · <category> ·
13
+ * <message>` (`describeProviderFailure` in the service): `402 ·
14
+ * invalid_request · This request would exceed your available credits…`.
15
+ */
16
+
17
+ export type NotRunKind = 'credits' | 'rate-limit' | 'key' | 'provider-error' | 'timeout' | 'runtime-unreachable' | 'runtime-error'
18
+
19
+ export interface CaseNotRun {
20
+ /** Whose side it is on: the model provider's, the turn's clock, or the local runtime's. */
21
+ cause: 'provider' | 'timeout' | 'runtime'
22
+ kind: NotRunKind
23
+ /** After "couldn't run: ": "the model provider refused (402 credits)". */
24
+ what: string
25
+ /** What fixes it, for the person. */
26
+ fix: string
27
+ /** Never sent: an earlier case met the same refusal, and the run stopped sending cases. */
28
+ skipped?: boolean
29
+ /** The provider that refused, when its words name it (`openrouter`, `anthropic`). */
30
+ provider?: string
31
+ }
32
+
33
+ /** Refusals every later case would meet as well: the account's credit or key, or a runtime that does not answer. */
34
+ const STOPS_THE_RUN = new Set<NotRunKind>(['credits', 'key', 'runtime-unreachable'])
35
+
36
+ export function stopsTheRun(n: CaseNotRun): boolean {
37
+ return STOPS_THE_RUN.has(n.kind)
38
+ }
39
+
40
+ const PROVIDERS = 'frontera local providers'
41
+
42
+ /** The fix for each kind; `again` is what the person does once it is fixed ("runs the cases again"). */
43
+ function fixFor(kind: NotRunKind, again = 'runs the cases again'): string {
44
+ switch (kind) {
45
+ case 'credits':
46
+ return `the model provider account is out of credit: the person adds credit there, or picks a model through another provider they hold a key for (\`${PROVIDERS}\` shows which provider each model uses), then ${again}`
47
+ case 'rate-limit':
48
+ return again === 'runs the cases again' ? 'the model provider is limiting requests: run the cases again later, or one at a time with `--concurrency 1`' : `the model provider is limiting requests: wait a minute, then ${again}`
49
+ case 'key':
50
+ return `the model provider refused the key: the person checks it and sets it again (\`frontera local key-set <provider>\`; \`${PROVIDERS}\` shows which provider each model uses), then ${again}`
51
+ case 'provider-error':
52
+ return `the model provider failed to answer: ${again.replace(/^runs /, 'run ')}; if it persists, check the provider’s status`
53
+ case 'timeout':
54
+ return 'the turn did not finish in time: run the case again; if it times out again, open its conversation in Studio (`frontera local ui`) to see where it stopped'
55
+ case 'runtime-unreachable':
56
+ return '`frontera local status` says whether the local runtime is running; `frontera local start` starts it'
57
+ case 'runtime-error':
58
+ return 'the local runtime failed the request: `frontera local logs service` says why; run the cases again once it answers'
59
+ }
60
+ }
61
+
62
+ /** The fix for a kind of refusal, with what the person does once it is fixed ("sends the message again"). */
63
+ export function notRunFix(kind: NotRunKind, again?: string): string {
64
+ return fixFor(kind, again)
65
+ }
66
+
67
+ const notRun = (cause: CaseNotRun['cause'], kind: NotRunKind, what: string): CaseNotRun => ({ cause, kind, what, fix: fixFor(kind) })
68
+
69
+ /** Which provider a refusal's words come from, when they say: OpenRouter's "can only afford", Anthropic's "credit balance is too low". */
70
+ function refusingProvider(text: string): string | null {
71
+ if (/openrouter|can only afford|requires more credits/i.test(text)) return 'openrouter'
72
+ if (/anthropic|credit balance is too low/i.test(text)) return 'anthropic'
73
+ return null
74
+ }
75
+
76
+ /** A model offered directly by a provider whose key the runtime holds (`directModelFor` in local-seed). */
77
+ export interface DirectModelHint {
78
+ slug: string
79
+ name: string
80
+ providerLabel: string
81
+ }
82
+
83
+ /**
84
+ * A credit refusal's fix with the model the person can switch to, when the
85
+ * runtime offers one directly from another provider they hold a key for:
86
+ * its slug, and how to make it the default (`frontera local model-default`),
87
+ * which is theirs to choose. Any other refusal is returned as it is.
88
+ */
89
+ export function withDirectModel(n: CaseNotRun, direct: DirectModelHint | null, again = 'run the cases again'): CaseNotRun {
90
+ if (n.kind !== 'credits' || !direct) return n
91
+ return {
92
+ ...n,
93
+ fix: `the model provider account is out of credit: the person adds credit there, or uses a model through another provider they hold a key for. ${/^[AEIOU]/i.test(direct.providerLabel) ? 'An' : 'A'} ${direct.providerLabel} key is stored, so the runtime offers ${direct.name} directly from ${direct.providerLabel} as \`${direct.slug}\` (\`${PROVIDERS}\` lists it): \`frontera local model-default ${direct.slug}\` makes it the model new Agents and test grading use, and the one an Agent runs on when the runtime lacks the model its agent.json names (\`frontera local dev --once --reapply\` applies that to Agents already applied); an Agent whose agent.json names a model the runtime has keeps it until its file names \`${direct.slug}\`. Then ${again}`,
94
+ }
95
+ }
96
+
97
+ /** A provider failure as the runtime words it: its status and category, when it says them. */
98
+ const DESCRIBED = /(?:^|[\s:"'(])(\d{3}) · (?:(provider_unavailable|rate_limited|authentication|invalid_request|out_of_credit|aborted) · )?/
99
+ const CATEGORY_ONLY = /(?:^|[\s:"'(])(provider_unavailable|rate_limited|authentication|out_of_credit) · /
100
+ const CREDIT = /credit|can only afford|insufficient (?:funds|balance|quota)|billing|payment required|quota/i
101
+ const RATE = /rate.?limit|too many requests|in-flight|in flight/i
102
+
103
+ /**
104
+ * Why a case's error means it couldn't run, or null when it is a failure of
105
+ * the case itself (a refused setup, a conversation waiting on a decision, a
106
+ * missing Agent). Only an error is classified, never a verdict on a reply.
107
+ */
108
+ export function caseCouldNotRun(message: string): CaseNotRun | null {
109
+ const text = message.replace(/\s+/g, ' ').trim()
110
+ if (!text) return null
111
+ const described = DESCRIBED.exec(text)
112
+ const status = described ? Number(described[1]) : undefined
113
+ const category = described?.[2] ?? CATEGORY_ONLY.exec(text)?.[1]
114
+ const fromProvider = !!described || !!category
115
+ // A 402 or 429 the provider worded without the runtime's form ("402 Payment Required: … credits").
116
+ const bare = !fromProvider ? /\b(402|429)\b/.exec(text) : null
117
+ const code = status ?? (bare ? Number(bare[1]) : undefined)
118
+
119
+ // `out_of_credit`: the runtime's own category for a credit refusal, whatever status the provider sent.
120
+ if (code === 402 || category === 'out_of_credit' || ((fromProvider || bare) && CREDIT.test(text) && code !== 429 && category !== 'rate_limited')) {
121
+ const provider = refusingProvider(text)
122
+ return { ...notRun('provider', 'credits', `the model provider refused (${code ?? 402} credits)`), ...(provider ? { provider } : {}) }
123
+ }
124
+ if (code === 429 || category === 'rate_limited' || (bare && RATE.test(text))) return notRun('provider', 'rate-limit', `the model provider refused (${code ?? 429} rate limit)`)
125
+ if (fromProvider && (code === 401 || code === 403 || category === 'authentication')) return notRun('provider', 'key', `the model provider refused the key${code ? ` (${code})` : ''}`)
126
+ if (fromProvider && ((code !== undefined && (code === 408 || code >= 500)) || category === 'provider_unavailable')) return notRun('provider', 'provider-error', `the model provider failed${code ? ` (${code})` : ''}`)
127
+
128
+ // The turn's own clock (the runner gives a turn 180 s, a call 60 s).
129
+ if (/operation timed out|TimeoutError|aborted due to timeout|timed out after/i.test(text)) return notRun('timeout', 'timeout', 'the turn did not finish in time')
130
+ // The local runtime did not answer at all, or failed the request.
131
+ if (/unable to connect|ECONNREFUSED|ConnectionRefused|fetch failed|socket connection was closed unexpectedly/i.test(text)) return notRun('runtime', 'runtime-unreachable', 'the local runtime did not answer')
132
+ const failed = /\banswered (5\d\d)\b/.exec(text)
133
+ if (failed) return notRun('runtime', 'runtime-error', `the local runtime failed (${failed[1]})`)
134
+ return null
135
+ }
136
+
137
+ /** The cases that couldn't run, by cause: one line each, with its fix and the cases. */
138
+ export function notRunGroups<T extends { notRun?: CaseNotRun; agent: string; name: string }>(results: readonly T[]): Array<{ what: string; fix: string; cause: CaseNotRun['cause']; cases: string[]; skipped: number }> {
139
+ const groups = new Map<string, { what: string; fix: string; cause: CaseNotRun['cause']; cases: string[]; skipped: number }>()
140
+ for (const r of results) {
141
+ if (!r.notRun) continue
142
+ const g = groups.get(r.notRun.what) ?? { what: r.notRun.what, fix: r.notRun.fix, cause: r.notRun.cause, cases: [], skipped: 0 }
143
+ g.cases.push(`${r.agent} · ${r.name}`)
144
+ if (r.notRun.skipped) g.skipped += 1
145
+ groups.set(r.notRun.what, g)
146
+ }
147
+ return [...groups.values()]
148
+ }
149
+
150
+ /** "13 couldn't run: the model provider refused (402 credits)", causes joined; empty when every case ran. */
151
+ export function notRunWords(groups: ReturnType<typeof notRunGroups>): string {
152
+ return groups.map((g) => `${g.cases.length} couldn’t run: ${g.what}`).join('; ')
153
+ }
@@ -2,6 +2,7 @@ import { existsSync, readFileSync } from 'node:fs'
2
2
  import { join } from 'node:path'
3
3
 
4
4
  import type { RemoteItem } from './local-sync-plan'
5
+ import type { LocalCredentials } from './local-state'
5
6
  import { itemRef, referencesIn, toIds, toNames, type Directory } from './local-sync-refs'
6
7
 
7
8
  /**
@@ -32,12 +33,12 @@ export interface ChatAppRow {
32
33
  }
33
34
 
34
35
  /** The configuration's sections a project file keeps, in this order. */
35
- const SECTIONS = ['presentation', 'roster', 'features', 'conversation', 'memory'] as const
36
+ const SECTIONS = ['presentation', 'roster', 'features', 'conversation', 'variables', 'memory'] as const
36
37
 
37
38
  export const chatAppPath = (slug: string) => `chat-apps/${slug}.json`
38
39
 
39
40
  /** Why a Chat App stays a draft locally, as the build and the Pull dialog say it. */
40
- export const CHAT_APP_DRAFT = 'Saved as the owner’s draft here and opened as its preview, its Agents running their drafts: a Chat App publishes only published Agents, and the local runtime’s Agents are drafts'
41
+ export const CHAT_APP_DRAFT = 'Saved as the owner’s draft here: Studio opens it at /apps/<slug> as its draft preview, its Agents running their drafts. It publishes once its Agents are published (a Chat App publishes only published Agents)'
41
42
 
42
43
  /**
43
44
  * The project's file for an App's live configuration: its Agents by name,
@@ -83,11 +84,45 @@ export function readChatApps(rows: ChatAppRow[], directory: Directory, wanted: (
83
84
 
84
85
  type Call = (method: string, path: string, body?: unknown) => Promise<{ status: number; body: unknown }>
85
86
 
87
+ /** Calls as the runtime's workspace key: the principal `frontera chat-app` commands act as in the project. */
88
+ export function keyCalls(serviceUrl: string, creds: LocalCredentials, fetcher: typeof fetch = fetch): Call {
89
+ return async (method, path, body) => {
90
+ const res = await fetcher(new URL(path, serviceUrl), {
91
+ method,
92
+ headers: {
93
+ authorization: `Bearer ${creds.workspaceKey ?? ''}`,
94
+ 'x-org-id': creds.organizationId,
95
+ 'x-workspace-id': creds.workspaceId,
96
+ ...(body === undefined ? {} : { 'content-type': 'application/json' }),
97
+ },
98
+ body: body === undefined ? undefined : JSON.stringify(body),
99
+ })
100
+ const text = await res.text()
101
+ let parsed: unknown = text
102
+ try {
103
+ parsed = text ? JSON.parse(text) : null
104
+ } catch {
105
+ // Keep the text.
106
+ }
107
+ return { status: res.status, body: parsed }
108
+ }
109
+ }
110
+
86
111
  const dataOf = (body: unknown) => ((body as { data?: unknown } | null)?.data ?? body) as Record<string, unknown> | null
87
112
 
88
- const errorOf = (a: { status: number; body: unknown }) => {
89
- const b = a.body as { message?: unknown } | null
90
- return `${a.status} ${String(typeof b?.message === 'string' ? b.message : JSON.stringify(b)).replace(/\s+/g, ' ').slice(0, 200)}`
113
+ /**
114
+ * A refusal with what the service said was wrong: the configuration's
115
+ * validation (`roster.agents.0.label: Required`) is in its `details`, which a
116
+ * bare "400 Invalid Chat App configuration" hides.
117
+ */
118
+ export const errorOf = (a: { status: number; body: unknown }) => {
119
+ const b = a.body as { message?: unknown; details?: unknown; data?: { details?: unknown } } | null
120
+ const message = String(typeof b?.message === 'string' ? b.message : JSON.stringify(b)).replace(/\s+/g, ' ').slice(0, 200)
121
+ const raw = b?.details ?? b?.data?.details
122
+ const details = (Array.isArray(raw) ? raw : raw && typeof raw === 'object' ? [raw] : [])
123
+ .map((d) => (typeof d === 'string' ? d : d && typeof d === 'object' ? [(d as { path?: unknown }).path, (d as { message?: unknown }).message].filter((x) => typeof x === 'string' && x).join(': ') || JSON.stringify(d) : String(d)))
124
+ .filter(Boolean)
125
+ return details.length ? `${a.status} ${message}: ${details.slice(0, 8).join('; ')}${details.length > 8 ? ` (and ${details.length - 8} more)` : ''}` : `${a.status} ${message}`
91
126
  }
92
127
 
93
128
  export interface ChatAppApplied {
@@ -107,7 +142,7 @@ export interface ChatAppApplied {
107
142
  * hold is left off the roster, and said; one it holds but the runtime does
108
143
  * not have yet holds the App back until it does.
109
144
  */
110
- export async function applyChatApp(root: string, slug: string, call: Call, directory: () => Promise<Directory>, provides: (ref: string) => boolean): Promise<ChatAppApplied> {
145
+ export async function applyChatApp(root: string, slug: string, call: Call, directory: () => Promise<Directory>, provides: (ref: string) => boolean, keyCall?: Call): Promise<ChatAppApplied> {
111
146
  const path = join(root, chatAppPath(slug))
112
147
  if (!existsSync(path)) return { ok: false, output: `error [CHAT_APP]: ${chatAppPath(slug)} is missing` }
113
148
  let doc: Record<string, unknown>
@@ -131,8 +166,10 @@ export async function applyChatApp(root: string, slug: string, call: Call, direc
131
166
  if (waiting.length) {
132
167
  return { ok: false, missing: waiting, output: [`error [UNRESOLVED_REFERENCE]: ${chatAppPath(slug)} names what this runtime does not have`, ...waiting.map((w) => ` ${w}: in the project, not applied yet`)].join('\n') }
133
168
  }
134
- // The live version when it is published (null), as the platform's editor drafts it.
135
- roster.agents = kept.map((e) => ({ ...e, agentVersionNumber: null }))
169
+ // A draft names no Agent version (null: the one live when it is published), as the platform's
170
+ // editor drafts it; on this runtime its preview runs each Agent's draft. The label and the
171
+ // description are the Agent's own unless the file gives the App's.
172
+ roster.agents = kept.map((e) => ({ ...e, agentVersionNumber: null, label: typeof e.label === 'string' && e.label.trim() ? e.label : null, description: typeof e.description === 'string' && e.description.trim() ? e.description : null }))
136
173
  const ids = new Set(kept.map((e) => e.agentConfigId))
137
174
  if (typeof roster.defaultAgentConfigId === 'string' && !ids.has(roster.defaultAgentConfigId)) roster.defaultAgentConfigId = kept[0]?.agentConfigId ?? null
138
175
  const { name: _name, description: _description, ...configuration } = { ...doc, roster } as Record<string, unknown>
@@ -151,20 +188,60 @@ export async function applyChatApp(root: string, slug: string, call: Call, direc
151
188
  }
152
189
  const draft = await call('PUT', `/v1/chat-apps/${encodeURIComponent(appId)}/draft`, { configuration: resolved })
153
190
  if (draft.status >= 400) return { ok: false, output: `error [CHAT_APP]: ${errorOf(draft)}` }
191
+ // What the runtime found wrong with its `variables` when it saved the draft: an error fails the apply, as it would block a publish.
192
+ const variableFindings = findingsOf(dataOf(draft.body)?.findings).filter((f) => f.code?.startsWith('variables.'))
193
+ const variableErrors = variableFindings.filter((f) => f.level === 'error')
194
+ // Drafts are per principal: the owner's is what Studio previews; the workspace key's is what
195
+ // `frontera chat-app get` reads in this project. Both hold the file.
196
+ if (keyCall) {
197
+ const keyDraft = await keyCall('PUT', `/v1/chat-apps/${encodeURIComponent(appId)}/draft`, { configuration: resolved })
198
+ if (keyDraft.status >= 400) lines.push(` warning: \`frontera chat-app get ${slug}\` shows the workspace key's own draft, which could not be saved (${errorOf(keyDraft)}); Studio shows the applied one`)
199
+ }
154
200
  const published = await call('POST', `/v1/chat-apps/${encodeURIComponent(appId)}/publish`, { configuration: resolved })
155
- if (published.status >= 400) {
156
- const findings = ((dataOf(published.body)?.details ?? (published.body as { details?: unknown } | null)?.details) ?? []) as Array<{ code?: string; message?: string }>
157
- const list = Array.isArray(findings) ? findings : []
158
- if (list.length && list.every((f) => f.code === 'agent.unpublished' || f.code === 'agent.unavailable' || f.code === 'roster.empty')) {
201
+ if (published.status < 400) {
202
+ // Published: its roster's Agents were published here, and people open the live version.
203
+ const version = (dataOf(published.body) as { version?: { version?: unknown } | null; live?: { version?: unknown } | null } | null)
204
+ const v = version?.version?.version ?? version?.live?.version
205
+ lines.push(` published Chat App ${name}${typeof v === 'string' || typeof v === 'number' ? ` v${v}` : ''} here: people open its live version, with its Agents’ published versions; Studio opens it at /apps/${slug} as its draft preview, its Agents running their drafts`)
206
+ } else {
207
+ // Its variables' findings were said from the draft; a runtime that gave none there says them here.
208
+ const list = findingsOf(dataOf(published.body)?.details ?? (published.body as { details?: unknown } | null)?.details).filter((f) => !(variableFindings.length && f.code?.startsWith('variables.')))
209
+ for (const f of list.filter((x) => x.code?.startsWith('variables.') && x.level !== 'error')) lines.push(` warning [${f.code}]: ${f.message ?? ''}`)
210
+ const blocking = list.filter((x) => !x.code?.startsWith('variables.') || x.level === 'error')
211
+ for (const f of blocking.filter((x) => x.code?.startsWith('variables.'))) variableErrors.push(f)
212
+ const rest = blocking.filter((x) => !x.code?.startsWith('variables.'))
213
+ if (rest.length && rest.every((f) => f.code === 'agent.unpublished' || f.code === 'agent.unavailable' || f.code === 'roster.empty')) {
159
214
  lines.push(
160
- list.some((f) => f.code === 'roster.empty')
215
+ rest.some((f) => f.code === 'roster.empty')
161
216
  ? ' warning: It has no Agent on its roster here, so it stays a draft'
162
- : ` warning: ${CHAT_APP_DRAFT}`,
217
+ : ` warning: ${CHAT_APP_DRAFT.replace('<slug>', slug)}`,
163
218
  )
164
- } else {
165
- lines.push(` warning: It stays a draft here: ${list.map((f) => f.message).filter(Boolean).join('; ') || errorOf(published)}`)
219
+ } else if (rest.length || !variableErrors.length) {
220
+ lines.push(` warning: It stays a draft here: ${rest.map((f) => f.message).filter(Boolean).join('; ') || errorOf(published)}`)
221
+ }
222
+ }
223
+ for (const f of variableFindings.filter((x) => x.level !== 'error')) lines.push(` warning [${f.code}]: ${f.message ?? ''}`)
224
+ if (variableErrors.length) {
225
+ return {
226
+ ok: false,
227
+ output: [
228
+ `error [CHAT_APP_VARIABLES]: ${chatAppPath(slug)}: its variables can't be given as written (the draft is saved, and can't be published until they can)`,
229
+ ...variableErrors.map((f) => ` ${f.code}: ${f.message ?? ''}`),
230
+ ...lines,
231
+ ].join('\n'),
166
232
  }
167
233
  }
168
234
  lines.unshift(` saved Chat App ${name}`)
169
235
  return { ok: true, output: lines.join('\n') }
170
236
  }
237
+
238
+ /** Validation findings as the runtime gives them (`level`, `code`, `message`), from a response's `findings` or an error's `details`. */
239
+ interface ChatAppFinding {
240
+ level?: 'error' | 'warning' | string
241
+ code?: string
242
+ message?: string
243
+ }
244
+
245
+ function findingsOf(raw: unknown): ChatAppFinding[] {
246
+ return Array.isArray(raw) ? raw.filter((f): f is ChatAppFinding => !!f && typeof f === 'object') : []
247
+ }
@@ -0,0 +1,83 @@
1
+ import { existsSync, readFileSync } from 'node:fs'
2
+ import { join, normalize } from 'node:path'
3
+
4
+ import { hash, readLock } from './local-sync-plan'
5
+
6
+ /**
7
+ * What a pulled project holds as the workspace gave it: each item whose files
8
+ * are all byte for byte what the last pull wrote (`frontera.lock`), and each
9
+ * such file. A failure only there is inherited from the workspace: the
10
+ * person's change did not make it, and fixing it means changing the
11
+ * workspace's own item. Empty for a project that was never pulled.
12
+ */
13
+ export interface Unchanged {
14
+ /** Item references (`function/staff-availability`, `dataset/media_x`) unchanged since the pull. */
15
+ refs: Set<string>
16
+ /** Project paths unchanged since the pull. */
17
+ files: Set<string>
18
+ }
19
+
20
+ export function unchangedSincePull(root: string): Unchanged {
21
+ const lock = readLock(root)
22
+ const refs = new Set<string>()
23
+ const files = new Set<string>()
24
+ if (!lock) return { refs, files }
25
+ for (const [ref, entry] of Object.entries(lock.items)) {
26
+ let all = Object.keys(entry.files ?? {}).length > 0
27
+ for (const [path, written] of Object.entries(entry.files ?? {})) {
28
+ const full = join(root, path)
29
+ const same = existsSync(full) && hash(readFileSync(full, 'utf8')) === written
30
+ if (same) files.add(normalize(path))
31
+ else all = false
32
+ }
33
+ if (all) refs.add(ref)
34
+ }
35
+ return { refs, files }
36
+ }
37
+
38
+ /**
39
+ * Whether a governed Action's semantic-type conflict is the workspace's: the
40
+ * Action's file, and each file the conflict rests on (the object type, its
41
+ * semantic type, the shared field it comes through), are all still as the
42
+ * pull wrote them. Then the Action deploys as the workspace runs it, with the
43
+ * conflict said as an inherited warning; one the person made (an edited
44
+ * Action, a narrowed type) is refused, as the platform refuses it.
45
+ */
46
+ export function inheritedFromPull(unchanged: Unchanged): (action: string, refs: readonly string[]) => boolean {
47
+ return (action, refs) => unchanged.refs.has(`blueprint/actions/${action}`) && refs.every((ref) => unchanged.refs.has(ref))
48
+ }
49
+
50
+ /**
51
+ * The warnings inherited from the workspace on the governed Actions' rows:
52
+ * each Action deployed as the workspace runs it, with a value it offers that
53
+ * the semantic type it writes refuses. Listed by `local check`; never a
54
+ * failure, even with `--strict`: the Action runs, as it does in the workspace.
55
+ */
56
+ export function inheritedActionWarnings(rows: ReadonlyArray<{ ref: string; warnings?: string[] }>): string[] {
57
+ return rows.flatMap((r) => (r.ref.startsWith('action/') ? r.warnings ?? [] : []))
58
+ }
59
+
60
+ /** The project files a typecheck's output names an error in (`functions/x/src/index.ts(411,5): error TS2322`). */
61
+ export function typecheckErrorFiles(output: string): string[] {
62
+ const out = new Set<string>()
63
+ for (const m of output.matchAll(/^\s*([^\s(:][^():\n]*\.(?:ts|tsx|mts|cts|js|jsx))(?:\(\d+,\d+\)|:\d+:\d+)\s*[:-]?\s*error\b/gm)) out.add(normalize(m[1]!.replace(/^\.\//, '')))
64
+ return [...out]
65
+ }
66
+
67
+ /**
68
+ * Whether every failure is inherited: there is at least one, and each is in
69
+ * an item or file unchanged since the pull. One failure the change could have
70
+ * made (an edited or new file) and the step is the change's.
71
+ */
72
+ export function allInherited(failures: readonly string[], inherited: ReadonlySet<string>): boolean {
73
+ return failures.length > 0 && failures.every((f) => inherited.has(f))
74
+ }
75
+
76
+ const BLUEPRINT_ROWS: Record<string, string> = { objectType: 'object-types', action: 'actions', linkType: 'link-types', metric: 'metrics', semanticType: 'semantic-types', sharedField: 'shared-fields', objectSet: 'object-sets' }
77
+
78
+ /** A status row's reference as the lock names the item: `objectType/Receipt` → `blueprint/object-types/Receipt`. */
79
+ export function lockRefOf(rowRef: string): string {
80
+ const at = rowRef.indexOf('/')
81
+ const folder = at > 0 ? BLUEPRINT_ROWS[rowRef.slice(0, at)] : undefined
82
+ return folder ? `blueprint/${folder}/${rowRef.slice(at + 1)}` : rowRef
83
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Cities sample data names for a custom `--region` point (`<lat>,<lon>[,<km>]`):
3
+ * the city a point is near, so a record's `city` agrees with its coordinates
4
+ * (e2e G4: a point on Denver gave every Site the made-up "West Ernesto").
5
+ *
6
+ * The large metropolitan areas of the countries sample data knows, and of a
7
+ * few more, with how far each reaches from its centre. A point near none of
8
+ * them keeps one made-up city for the whole region, as before.
9
+ */
10
+
11
+ export interface City {
12
+ name: string
13
+ lat: number
14
+ lon: number
15
+ /** How far the city reaches from its centre, in kilometres. */
16
+ reachKm: number
17
+ }
18
+
19
+ const c = (name: string, lat: number, lon: number, reachKm = 25): City => ({ name, lat, lon, reachKm })
20
+
21
+ export const CITIES: readonly City[] = [
22
+ // United States
23
+ c('New York', 40.71, -74.01, 35), c('Los Angeles', 34.05, -118.24, 40), c('Chicago', 41.88, -87.63, 35), c('Houston', 29.76, -95.37, 35),
24
+ c('Phoenix', 33.45, -112.07, 30), c('Philadelphia', 39.95, -75.17), c('San Antonio', 29.42, -98.49), c('San Diego', 32.72, -117.16),
25
+ c('Dallas', 32.78, -96.8, 35), c('Austin', 30.27, -97.74), c('San Jose', 37.34, -121.89, 20), c('San Francisco', 37.77, -122.42, 20),
26
+ c('Seattle', 47.61, -122.33), c('Denver', 39.74, -104.99, 30), c('Boston', 42.36, -71.06), c('Washington', 38.91, -77.04),
27
+ c('Atlanta', 33.75, -84.39, 30), c('Miami', 25.76, -80.19), c('Minneapolis', 44.98, -93.27), c('Detroit', 42.33, -83.05),
28
+ c('Portland', 45.52, -122.68), c('Las Vegas', 36.17, -115.14), c('Salt Lake City', 40.76, -111.89), c('Nashville', 36.16, -86.78),
29
+ c('Charlotte', 35.23, -80.84), c('Kansas City', 39.1, -94.58), c('St. Louis', 38.63, -90.2), c('Orlando', 28.54, -81.38),
30
+ c('Tampa', 27.95, -82.46), c('Pittsburgh', 40.44, -79.99), c('Cleveland', 41.5, -81.69), c('Columbus', 39.96, -83.0),
31
+ c('Indianapolis', 39.77, -86.16), c('Baltimore', 39.29, -76.61, 20), c('Sacramento', 38.58, -121.49), c('New Orleans', 29.95, -90.07),
32
+ c('Honolulu', 21.31, -157.86), c('Anchorage', 61.22, -149.9), c('Albuquerque', 35.08, -106.65), c('Oklahoma City', 35.47, -97.52),
33
+ c('Raleigh', 35.78, -78.64), c('Milwaukee', 43.04, -87.91),
34
+ // Canada, Mexico and South America
35
+ c('Toronto', 43.65, -79.38, 30), c('Montreal', 45.5, -73.57), c('Vancouver', 49.28, -123.12), c('Calgary', 51.05, -114.07),
36
+ c('Mexico City', 19.43, -99.13, 35), c('São Paulo', -23.55, -46.63, 35), c('Rio de Janeiro', -22.91, -43.17, 30), c('Brasília', -15.79, -47.88),
37
+ c('Belo Horizonte', -19.92, -43.94), c('Salvador', -12.97, -38.5), c('Porto Alegre', -30.03, -51.23), c('Recife', -8.05, -34.88),
38
+ c('Fortaleza', -3.73, -38.53), c('Curitiba', -25.43, -49.27), c('Buenos Aires', -34.6, -58.38, 30), c('Santiago', -33.45, -70.67),
39
+ c('Bogotá', 4.71, -74.07), c('Lima', -12.05, -77.04),
40
+ // Indonesia, Malaysia, Singapore, the Philippines and the rest of Asia
41
+ c('Jakarta', -6.2, 106.82, 30), c('Surabaya', -7.25, 112.75), c('Bandung', -6.92, 107.61), c('Medan', 3.59, 98.67),
42
+ c('Semarang', -6.97, 110.42), c('Makassar', -5.15, 119.43), c('Yogyakarta', -7.8, 110.36, 20), c('Denpasar', -8.65, 115.22, 20),
43
+ c('Palembang', -2.98, 104.75), c('Balikpapan', -1.24, 116.85), c('Kuala Lumpur', 3.14, 101.69), c('George Town', 5.41, 100.33),
44
+ c('Johor Bahru', 1.49, 103.74, 15), c('Kota Kinabalu', 5.98, 116.07), c('Kuching', 1.55, 110.36), c('Singapore', 1.35, 103.82, 15),
45
+ c('Manila', 14.6, 120.98), c('Cebu City', 10.32, 123.89), c('Davao City', 7.19, 125.46), c('Bangkok', 13.76, 100.5, 30),
46
+ c('Ho Chi Minh City', 10.82, 106.63), c('Hanoi', 21.03, 105.85), c('Hong Kong', 22.32, 114.17, 20), c('Seoul', 37.57, 126.98, 30),
47
+ c('Shanghai', 31.23, 121.47, 35), c('Beijing', 39.9, 116.41, 35), c('Tokyo', 35.68, 139.69, 30), c('Osaka', 34.69, 135.5),
48
+ c('Nagoya', 35.18, 136.91), c('Sapporo', 43.06, 141.35), c('Fukuoka', 33.59, 130.4), c('Kyoto', 35.01, 135.77, 15),
49
+ c('Sendai', 38.27, 140.87), c('Hiroshima', 34.39, 132.46), c('Mumbai', 19.08, 72.88, 30), c('Delhi', 28.61, 77.21, 35),
50
+ c('Bengaluru', 12.97, 77.59, 30), c('Hyderabad', 17.39, 78.49, 30), c('Chennai', 13.08, 80.27), c('Kolkata', 22.57, 88.36),
51
+ c('Pune', 18.52, 73.86), c('Ahmedabad', 23.02, 72.57), c('Jaipur', 26.91, 75.79),
52
+ // Oceania
53
+ c('Sydney', -33.87, 151.21, 35), c('Melbourne', -37.81, 144.96, 35), c('Brisbane', -27.47, 153.03, 30), c('Perth', -31.95, 115.86, 30),
54
+ c('Adelaide', -34.93, 138.6), c('Canberra', -35.28, 149.13, 15), c('Hobart', -42.88, 147.33, 15), c('Darwin', -12.46, 130.84, 15),
55
+ c('Gold Coast', -28.02, 153.4, 20), c('Auckland', -36.85, 174.76),
56
+ // Europe
57
+ c('London', 51.51, -0.12, 30), c('Manchester', 53.48, -2.24), c('Birmingham', 52.49, -1.89), c('Leeds', 53.8, -1.55, 20),
58
+ c('Glasgow', 55.86, -4.25, 20), c('Edinburgh', 55.95, -3.19, 15), c('Liverpool', 53.41, -2.98, 20), c('Bristol', 51.45, -2.59, 15),
59
+ c('Cardiff', 51.48, -3.18, 15), c('Belfast', 54.6, -5.93, 15), c('Newcastle upon Tyne', 54.98, -1.61, 15), c('Dublin', 53.35, -6.26),
60
+ c('Berlin', 52.52, 13.4), c('Hamburg', 53.55, 9.99), c('Munich', 48.14, 11.58), c('Cologne', 50.94, 6.96, 20),
61
+ c('Frankfurt', 50.11, 8.68, 20), c('Stuttgart', 48.78, 9.18, 20), c('Düsseldorf', 51.23, 6.77, 15), c('Leipzig', 51.34, 12.37, 15),
62
+ c('Paris', 48.86, 2.35), c('Marseille', 43.3, 5.37), c('Lyon', 45.76, 4.84, 20), c('Toulouse', 43.6, 1.44, 20),
63
+ c('Nice', 43.7, 7.27, 15), c('Bordeaux', 44.84, -0.58, 20), c('Lille', 50.63, 3.06, 15), c('Nantes', 47.22, -1.55, 15),
64
+ c('Strasbourg', 48.57, 7.75, 15), c('Madrid', 40.42, -3.7), c('Barcelona', 41.39, 2.17), c('Valencia', 39.47, -0.38, 20),
65
+ c('Seville', 37.39, -5.98, 20), c('Bilbao', 43.26, -2.93, 15), c('Málaga', 36.72, -4.42, 15), c('Lisbon', 38.72, -9.14, 20),
66
+ c('Amsterdam', 52.37, 4.9, 20), c('Rotterdam', 51.92, 4.48, 15), c('The Hague', 52.08, 4.3, 10), c('Utrecht', 52.09, 5.12, 10),
67
+ c('Eindhoven', 51.44, 5.47, 10), c('Brussels', 50.85, 4.35, 20), c('Zurich', 47.38, 8.54, 15), c('Vienna', 48.21, 16.37, 20),
68
+ c('Rome', 41.9, 12.5), c('Milan', 45.46, 9.19), c('Copenhagen', 55.68, 12.57, 20), c('Stockholm', 59.33, 18.07),
69
+ c('Oslo', 59.91, 10.75, 20), c('Warsaw', 52.23, 21.01), c('Istanbul', 41.01, 28.98, 35),
70
+ // The Middle East and Africa
71
+ c('Dubai', 25.2, 55.27, 30), c('Abu Dhabi', 24.45, 54.38), c('Doha', 25.29, 51.53, 20), c('Riyadh', 24.71, 46.68, 30),
72
+ c('Cairo', 30.04, 31.24, 30), c('Lagos', 6.52, 3.38, 30), c('Nairobi', -1.29, 36.82), c('Johannesburg', -26.2, 28.05, 30),
73
+ c('Cape Town', -33.92, 18.42),
74
+ ]
75
+
76
+ /**
77
+ * The city a custom region's point is in or near: the nearest city whose reach,
78
+ * widened by the region's own radius (at most 60 km), holds the point. Null
79
+ * when the point is near none of them.
80
+ */
81
+ export function cityNear(point: { lat: number; lon: number }, radiusKm: number, distanceKm: (a: { lat: number; lon: number }, b: { lat: number; lon: number }) => number): City | null {
82
+ let best: { city: City; d: number } | null = null
83
+ for (const city of CITIES) {
84
+ const d = distanceKm(city, point)
85
+ if (d > city.reachKm + Math.min(radiusKm, 60)) continue
86
+ if (!best || d < best.d) best = { city, d }
87
+ }
88
+ return best?.city ?? null
89
+ }