@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
@@ -135,7 +135,7 @@ export function serviceEnv(args: {
135
135
  runnerEnabled: boolean
136
136
  jobsEnabled: boolean
137
137
  storageEnabled: boolean
138
- /** Embed with the keyless local embeddings (no OpenAI or OpenRouter key stored). */
138
+ /** Point the service's OpenAI client at the keyless local embeddings (no OpenAI key stored). */
139
139
  keylessEmbeddings?: boolean
140
140
  /** The customer documentation publication the service serves to coding agents. */
141
141
  documentationBundle?: string | null
@@ -145,6 +145,8 @@ export function serviceEnv(args: {
145
145
  const studio = `http://127.0.0.1:${ports.studio}`
146
146
  const env: Record<string, string> = {
147
147
  NODE_ENV: 'development',
148
+ // A local runtime: the service admits exact loopback origins (a Custom App's dev server on any port).
149
+ FRONTERA_LOCAL_RUNTIME: '1',
148
150
  PORT: String(ports.service),
149
151
  DATABASE_URL: databaseUrl(secrets, ports),
150
152
  AGENT_CONFIG_REDIS_URL: `redis://127.0.0.1:${ports.cache}`,
@@ -164,9 +166,22 @@ export function serviceEnv(args: {
164
166
  FUNCTIONS_ALLOW_LOCAL_HTTP: '1',
165
167
  // Every local Agent is a draft: a Chat App's draft preview offers them, each run on its draft.
166
168
  CHAT_APP_PREVIEW_DRAFT_AGENTS: '1',
169
+ // App previews load from this machine (`*.localhost` is loopback), never a public domain a
170
+ // checkout's own .env.local names; the empty values switch the public address zones off.
171
+ APPS_WILDCARD_DOMAIN: `http://apps.localhost:${ports.service}`,
172
+ APPS_ADDRESS_ZONE: '',
173
+ CHAT_APPS_ADDRESS_ZONE: '',
174
+ SITES_WILDCARD_DOMAIN: '',
167
175
  // So are the MCP servers an FDE is building: the operator's exemption from
168
176
  // the private-address rule, and on a laptop the FDE is the operator.
169
177
  MCP_ALLOWED_PRIVATE_HOSTS: `127.0.0.1,localhost,${HOST_FROM_CONTAINER}`,
178
+ // No Agent computer: a checkout's .env can name a Daytona account (the service loads it from
179
+ // its directory), and Agents here would run bash, files and a browser on that remote machine
180
+ // while `local status` says they have none. Empty values win over the file; a release image
181
+ // has none either.
182
+ DAYTONA_API_KEY: '',
183
+ DAYTONA_API_URL: '',
184
+ DAYTONA_TARGET: '',
170
185
  // Traces go to the runtime's own local traces, read back by Studio.
171
186
  LANGFUSE_BASE_URL: `http://127.0.0.1:${ports.traces}`,
172
187
  LANGFUSE_PUBLIC_KEY: LOCAL_TRACE_KEYS.publicKey,
@@ -0,0 +1,104 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+
4
+ import matter from 'gray-matter'
5
+
6
+ import { checkAgainstSchema, type SchemaProblem } from './local-schema-check'
7
+ import { cliSchema, type SchemaKind } from './local-schemas'
8
+
9
+ /**
10
+ * A Chat App file, a knowledge base's `knowledge.json` and a skill's
11
+ * `SKILL.md` front matter, checked against `frontera local schema <kind>`
12
+ * before `local dev` applies them.
13
+ *
14
+ * The platform keeps only the keys it knows and drops the rest without a word
15
+ * (a Chat App's `experience` block applied "✓" and was gone), so a key the
16
+ * format does not have is a warning that says it was left out. A value of the
17
+ * wrong shape (`roster.agents.0.agent` where `agentConfigId` belongs) is an
18
+ * error with its path, refused before the call rather than after it as a bare
19
+ * "Invalid configuration".
20
+ */
21
+
22
+ export interface FileChecks {
23
+ errors: string[]
24
+ warnings: string[]
25
+ }
26
+
27
+ const UNKNOWN_KEY = /^`([^`]+)` is not a key this file takes/
28
+
29
+ /** Split schema problems: an unknown key is left out (a warning); anything else is refused. */
30
+ export function splitProblems(file: string, problems: SchemaProblem[], what: string): FileChecks {
31
+ const errors: string[] = []
32
+ const warnings: string[] = []
33
+ for (const p of problems) {
34
+ const unknown = UNKNOWN_KEY.exec(p.message)
35
+ if (unknown) {
36
+ const takes = /\(it takes (.*)\)$/.exec(p.message)?.[1]
37
+ warnings.push(` warning: ${file} ${p.path}: ${what} has no \`${unknown[1]}\`, so it is left out${takes ? ` (it takes ${takes})` : ''}`)
38
+ } else errors.push(`${file} ${p.path || '(the file)'}: ${p.message}`)
39
+ }
40
+ return { errors, warnings }
41
+ }
42
+
43
+ function check(kind: SchemaKind, file: string, doc: unknown, what: string): FileChecks {
44
+ const schema = cliSchema(kind)
45
+ if (!schema) return { errors: [], warnings: [] }
46
+ return splitProblems(file, checkAgainstSchema(doc, schema), what)
47
+ }
48
+
49
+ /** A Chat App document (`chat-apps/<slug>.json`). */
50
+ export function chatAppFileChecks(file: string, doc: unknown): FileChecks {
51
+ return check('chat-app', file, doc, 'a Chat App')
52
+ }
53
+
54
+ /**
55
+ * A knowledge base's file. Unlike a Chat App's, nothing in it is refused
56
+ * here: `local dev` sends only the settings it knows, so a wrong value is a
57
+ * warning and the platform's answer decides.
58
+ */
59
+ export function knowledgeFileChecks(file: string, doc: unknown): FileChecks {
60
+ const { errors, warnings } = check('knowledge', file, doc, 'knowledge.json')
61
+ return { errors: [], warnings: [...warnings, ...errors.map((e) => ` warning: ${e}`)] }
62
+ }
63
+
64
+ /** A skill's front matter. A missing name refuses the push (it is the skill's identity); the rest are warnings. */
65
+ export function skillFileChecks(file: string, text: string): FileChecks {
66
+ let data: Record<string, unknown>
67
+ try {
68
+ data = matter(text).data as Record<string, unknown>
69
+ } catch (err) {
70
+ return { errors: [`${file}: its front matter is not valid YAML: ${(err as Error).message}`], warnings: [] }
71
+ }
72
+ const schema = cliSchema('skill')!
73
+ const known = Object.keys((schema.properties ?? {}) as Record<string, unknown>)
74
+ const warnings = Object.keys(data)
75
+ .filter((k) => !known.includes(k))
76
+ .map((k) => ` warning: ${file}: the front matter has no \`${k}\`, so it is ignored (it takes ${known.map((n) => `\`${n}\``).join(', ')})`)
77
+ const errors: string[] = []
78
+ for (const p of checkAgainstSchema(data, schema)) {
79
+ if (p.message.includes('`name` is required')) errors.push(`${file}: its front matter needs \`name\`: the skill's identity, what agent.json binds as skill/<name>`)
80
+ else warnings.push(` warning: ${file} ${p.path || 'front matter'}: ${p.message}`)
81
+ }
82
+ return { errors, warnings }
83
+ }
84
+
85
+ /** The checks for one target's file, read from the project; nothing for a kind these do not cover. */
86
+ export function projectFileChecks(root: string, target: { kind: string; file?: string }): FileChecks {
87
+ const none = { errors: [], warnings: [] }
88
+ if (!target.file || !['chat-app', 'knowledge', 'skill'].includes(target.kind)) return none
89
+ let text: string
90
+ try {
91
+ text = readFileSync(join(root, target.file), 'utf8')
92
+ } catch {
93
+ return none
94
+ }
95
+ if (target.kind === 'skill') return skillFileChecks(target.file, text)
96
+ let doc: unknown
97
+ try {
98
+ doc = JSON.parse(text)
99
+ } catch {
100
+ // Unreadable: the apply says so with the file's own error.
101
+ return none
102
+ }
103
+ return target.kind === 'chat-app' ? chatAppFileChecks(target.file, doc) : knowledgeFileChecks(target.file, doc)
104
+ }
@@ -0,0 +1,141 @@
1
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
2
+ import { join, relative, sep } from 'node:path'
3
+
4
+ import type { Directory } from './local-sync-refs'
5
+
6
+ /**
7
+ * Object type ids a pulled Function's code hard-codes (pull D-3).
8
+ *
9
+ * A Function written against the workspace sometimes names an object type by
10
+ * the platform's id (`const SERVICE_REQUEST = 'b17f…'`), most often for an
11
+ * Action's `subjectRef`. The local runtime gives every object type its own
12
+ * id, so that code names a type the runtime doesn't have: an edit submitted
13
+ * there is refused, or worse, read as the wrong record. The pull knows the
14
+ * workspace's ids (the snapshot's directory), so it can say where.
15
+ *
16
+ * Two things are done with them:
17
+ *
18
+ * - **Said**, at pull and at each `local dev` apply: `<file>:<line>
19
+ * hard-codes <Type>’s platform id`, with the fix (read the id from the
20
+ * query result: `subjectOf`, or the result's `objectTypeId`).
21
+ * - **Adapted**, in the bundle `local dev` deploys and nowhere else: each
22
+ * quoted platform id becomes the local runtime's id for the same type
23
+ * (`bundleIdMap`, applied by `function deploy` when `local dev` passes the
24
+ * map in FRONTERA_BUNDLE_ID_MAP). The files keep the platform's ids, so
25
+ * what is pushed or deployed to the workspace is what was pulled.
26
+ *
27
+ * Only an exact match of a whole UUID the workspace gave an object type is
28
+ * touched: nothing else in the code can be that string by accident.
29
+ */
30
+
31
+ const UUID = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi
32
+
33
+ /** One hard-coded platform id: the project file, its 1-based line, the id and the object type it names. */
34
+ export interface HardCodedId {
35
+ file: string
36
+ line: number
37
+ id: string
38
+ objectType: string
39
+ }
40
+
41
+ /** The workspace's object type ids, id → apiName, from a pull's directory. */
42
+ export function platformObjectTypeIds(directory: Directory | undefined): Map<string, string> {
43
+ const out = new Map<string, string>()
44
+ for (const [name, id] of directory?.objectType ?? []) out.set(id.toLowerCase(), name)
45
+ return out
46
+ }
47
+
48
+ const isCode = (path: string) => /\.(ts|tsx|mts|js|mjs)$/.test(path) && !/\.test\.[a-z]+$/.test(path) && !path.endsWith('.d.ts')
49
+
50
+ /** Each platform object type id the files' code (not their tests) hard-codes, in file and line order. */
51
+ export function hardCodedObjectTypeIds(files: Record<string, string>, platformIds: ReadonlyMap<string, string>): HardCodedId[] {
52
+ if (!platformIds.size) return []
53
+ const out: HardCodedId[] = []
54
+ for (const [file, text] of Object.entries(files).sort(([a], [b]) => a.localeCompare(b))) {
55
+ if (!isCode(file)) continue
56
+ text.split('\n').forEach((line, i) => {
57
+ for (const m of line.matchAll(UUID)) {
58
+ const objectType = platformIds.get(m[0].toLowerCase())
59
+ if (objectType) out.push({ file, line: i + 1, id: m[0], objectType })
60
+ }
61
+ })
62
+ }
63
+ return out
64
+ }
65
+
66
+ /** One sentence per hard-coded id, as the pull and `local dev` say it. */
67
+ export function hardCodedIdLine(hit: HardCodedId, adapted = false): string {
68
+ return `${hit.file}:${hit.line} hard-codes ${hit.objectType}’s platform id${adapted ? '; the bundle applied here uses this runtime’s id for it instead (the file is unchanged)' : ', which the local runtime doesn’t have'}. Take the id from the query result instead (\`subjectOf(result, row, id)\`, or \`result.objectTypeId\`), so the code holds on any runtime`
69
+ }
70
+
71
+ /** A Function's code files by project path: its file, or every file in its folder (`functions/<slug>/`). */
72
+ export function functionCodeFiles(root: string, file: string, folder: boolean): Record<string, string> {
73
+ const out: Record<string, string> = {}
74
+ const read = (rel: string) => {
75
+ try {
76
+ out[rel] = readFileSync(join(root, rel), 'utf8')
77
+ } catch {
78
+ // Gone since it was listed: nothing to say about it.
79
+ }
80
+ }
81
+ if (!folder) {
82
+ read(file)
83
+ return out
84
+ }
85
+ const parts = file.split(/[\\/]/)
86
+ const dir = join(root, parts[0]!, parts[1]!)
87
+ const walk = (at: string) => {
88
+ for (const name of readdirSync(at)) {
89
+ if (name === 'node_modules' || name.startsWith('.')) continue
90
+ const path = join(at, name)
91
+ if (statSync(path).isDirectory()) walk(path)
92
+ else if (isCode(name)) read(relative(root, path).split(sep).join('/'))
93
+ }
94
+ }
95
+ if (existsSync(dir)) walk(dir)
96
+ return out
97
+ }
98
+
99
+ /**
100
+ * Platform id → this runtime's id, for each object type both know by the
101
+ * same apiName and whose ids differ: what `function deploy` puts in the bundle
102
+ * in place of the hard-coded one. Lowercase keys.
103
+ */
104
+ export function bundleIdMap(hits: readonly HardCodedId[], localIds: ReadonlyMap<string, string> | undefined): Record<string, string> {
105
+ const out: Record<string, string> = {}
106
+ for (const h of hits) {
107
+ const local = localIds?.get(h.objectType)
108
+ if (local && local.toLowerCase() !== h.id.toLowerCase()) out[h.id.toLowerCase()] = local
109
+ }
110
+ return out
111
+ }
112
+
113
+ /** The environment variable `local dev` hands `function deploy` the map in. */
114
+ export const BUNDLE_ID_MAP_ENV = 'FRONTERA_BUNDLE_ID_MAP'
115
+
116
+ /**
117
+ * The built code with each quoted platform id (`'…'`, `"…"` or a template
118
+ * literal) replaced by its local one, case kept as the map's value gives it.
119
+ * Unquoted text (a comment) is left as it is.
120
+ */
121
+ export function mapBundleIds(code: string, map: Readonly<Record<string, string>>): string {
122
+ if (!Object.keys(map).length) return code
123
+ return code.replace(/(['"`])([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})\1/g, (whole, quote: string, id: string) => {
124
+ const local = map[id.toLowerCase()]
125
+ return local ? `${quote}${local}${quote}` : whole
126
+ })
127
+ }
128
+
129
+ /** The map from FRONTERA_BUNDLE_ID_MAP, or empty when unset or not a map of ids. */
130
+ export function bundleIdMapFromEnv(env: Record<string, string | undefined> = process.env): Record<string, string> {
131
+ const raw = env[BUNDLE_ID_MAP_ENV]
132
+ if (!raw) return {}
133
+ try {
134
+ const parsed = JSON.parse(raw) as unknown
135
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {}
136
+ const ok = (v: unknown): v is string => typeof v === 'string' && /^[0-9a-fA-F-]{36}$/.test(v)
137
+ return Object.fromEntries(Object.entries(parsed).filter(([k, v]) => ok(k) && ok(v)).map(([k, v]) => [k.toLowerCase(), v as string]))
138
+ } catch {
139
+ return {}
140
+ }
141
+ }
@@ -0,0 +1,141 @@
1
+ import { posix } from 'node:path'
2
+
3
+ import { mergeRecovered, recoverFromBundle, recoveredFiles, type RecoveredModule } from './function-bundle-recovery'
4
+ import type { RemoteItem } from './local-sync-plan'
5
+
6
+ /**
7
+ * A pull's Functions whose source archive lacks a module their code imports,
8
+ * and what it does about them once every Function is read.
9
+ *
10
+ * In order: a copy another Function's source holds (the same file, kept by a
11
+ * current deploy); else the module cut back out of the Function's deployed
12
+ * bundle (`function-bundle-recovery.ts`), one file for every Function that
13
+ * needs it; else the reason, with today's remedy (deploy again from its source
14
+ * with a current CLI). A platform without the bundle route gets today's note.
15
+ */
16
+ export interface MissingImports {
17
+ it: RemoteItem
18
+ slug: string
19
+ version: number
20
+ /** Its source archive, by archive path. */
21
+ files: ReadonlyMap<string, string>
22
+ entry: string
23
+ /** By path from its folder, without extension (`../shared/eligibility`). */
24
+ missing: string[]
25
+ /** Whether a pull may write this project path (without extension). */
26
+ writable: (projectPath: string) => boolean
27
+ }
28
+
29
+ const list = (paths: string[]) => (paths.length > 3 ? `${paths.slice(0, 3).join(', ')} and ${paths.length - 3} more` : paths.length > 1 ? `${paths.slice(0, -1).join(', ')} and ${paths[paths.length - 1]}` : paths.join(''))
30
+
31
+ export function missingImportNote(lacking: string[], why?: string): string {
32
+ return `Imports ${list(lacking)}, which its source in the workspace doesn’t hold${why ? ` and its deployed bundle couldn’t give back (${why})` : ''}: it was deployed before a deploy kept the files a Function imports from beside its folder, so it can’t build here until the project has ${lacking.length === 1 ? 'that file' : 'those files'}. Deploy it again from its source with a current CLI, then pull it`
33
+ }
34
+
35
+ export function missingImportWarning(slug: string, lacking: string[], why?: string): string {
36
+ return `Function ${slug}: imports ${list(lacking)}, which its source in the workspace doesn’t hold${why ? ` and its deployed bundle couldn’t give back (${why})` : ''}; deploy it again from its source with a current CLI, then pull it`
37
+ }
38
+
39
+ /** A 404 from a platform that has no bundle route, rather than one that refused this version. */
40
+ function olderPlatform(err: unknown): boolean {
41
+ const message = (err as Error)?.message ?? ''
42
+ return /answered 404/.test(message) && (/Route not found/i.test(message) || /answered 404: (NOT_FOUND)?\s*$/.test(message))
43
+ }
44
+
45
+ export async function recoverMissingImports(
46
+ pending: MissingImports[],
47
+ items: RemoteItem[],
48
+ getBytes: (path: string) => Promise<Uint8Array>,
49
+ warnings: string[],
50
+ ): Promise<void> {
51
+ const project = (p: MissingImports, m: string) => posix.normalize(posix.join('functions', p.slug, m))
52
+ // A real copy of the file in another Function's source wins over anything recovered.
53
+ const real = (path: string): { file: string; content: string; by: RemoteItem }[] => {
54
+ for (const ext of ['.ts', '.tsx', '/index.ts', '.js', '/index.js']) {
55
+ const by = items.find((i) => i.kind === 'function' && i.files[`${path}${ext}`] !== undefined && !i.recovered?.includes(`${path}${ext}`))
56
+ if (!by) continue
57
+ const file = `${path}${ext}`
58
+ const types = file.endsWith('.js') ? `${file.slice(0, -'.js'.length)}.d.ts` : null
59
+ return [{ file, content: by.files[file]!, by }, ...(types && by.files[types] !== undefined ? [{ file: types, content: by.files[types]!, by }] : [])]
60
+ }
61
+ return []
62
+ }
63
+
64
+ // What the pull holds as source, from any Function's archive: a recovered module imports it rather than recovering it.
65
+ const elsewhere = new Map<string, string>()
66
+ for (const i of items) {
67
+ if (i.kind !== 'function') continue
68
+ for (const [file, content] of Object.entries(i.files)) {
69
+ if (/\.(ts|tsx|mts)$/.test(file) && !file.endsWith('.d.ts') && !i.recovered?.includes(file)) elsewhere.set(file.replace(/\.[^./]+$/, ''), content)
70
+ }
71
+ }
72
+ const recovered = new Map<string, RecoveredModule>()
73
+ const holders = new Map<string, MissingImports[]>()
74
+ const failed = new Map<MissingImports, Record<string, string>>()
75
+ const unread = new Map<MissingImports, string | null>()
76
+ for (const p of pending) {
77
+ const fromOthers: string[] = []
78
+ const remaining = p.missing.filter((m) => {
79
+ const copies = real(project(p, m))
80
+ if (!copies.length) return true
81
+ for (const c of copies) p.it.files[c.file] = c.content
82
+ fromOthers.push(`${copies[0]!.file} (from Function ${copies[0]!.by.name})`)
83
+ return false
84
+ })
85
+ if (fromOthers.length) p.it.notes.push(`Brings ${list(fromOthers)}, which it imports from beside its folder and another Function’s source in the workspace holds`)
86
+ if (!remaining.length) continue
87
+ let bundle: string
88
+ try {
89
+ bundle = new TextDecoder().decode(await getBytes(`/v1/functions/${encodeURIComponent(p.slug)}/versions/${p.version}/bundle`))
90
+ } catch (err) {
91
+ unread.set(p, olderPlatform(err) ? null : `its deployed bundle couldn’t be read: ${(err as Error).message}`)
92
+ continue
93
+ }
94
+ const result = recoverFromBundle({ slug: p.slug, version: p.version, bundle, folder: posix.join('functions', p.slug), files: p.files, entry: p.entry, missing: remaining, writable: p.writable, elsewhere })
95
+ failed.set(p, result.failed)
96
+ for (const [path, names] of Object.entries(result.testOnly)) {
97
+ p.it.notes.push(`Its tests import ${names.map((n) => `\`${n}\``).join(', ')} from ${path}, which its deployed bundle doesn’t hold (the bundle holds only what the Function runs): those tests need the original source`)
98
+ }
99
+ for (const mod of result.modules) {
100
+ const had = recovered.get(mod.path)
101
+ const merged = had ? mergeRecovered(had, mod) : mod
102
+ if (!merged) {
103
+ result.failed[mod.path] = `the deployed bundles of ${had!.from.join(', ')} and ${p.slug} hold code for it that can’t be one file`
104
+ continue
105
+ }
106
+ recovered.set(mod.path, merged)
107
+ holders.set(mod.path, [...(holders.get(mod.path) ?? []), p])
108
+ }
109
+ }
110
+
111
+ // One file for every Function that needs it: the union of what their bundles held.
112
+ const wrote = new Map<MissingImports, string[]>()
113
+ for (const [path, mod] of recovered) {
114
+ const files = recoveredFiles(mod)
115
+ for (const p of holders.get(path) ?? []) {
116
+ Object.assign(p.it.files, files)
117
+ p.it.recovered = [...new Set([...(p.it.recovered ?? []), ...Object.keys(files)])].sort()
118
+ wrote.set(p, [...(wrote.get(p) ?? []), ...Object.keys(files).filter((f) => f.endsWith('.js'))])
119
+ }
120
+ }
121
+
122
+ for (const p of pending) {
123
+ const done = wrote.get(p) ?? []
124
+ if (done.length) {
125
+ p.it.notes.push(`Recovered ${list(done.sort())} from its deployed bundle (v${p.version}): its source in the workspace doesn’t hold ${done.length === 1 ? 'that file' : 'those files'}. ${done.length === 1 ? 'It is' : 'They are'} bundled JavaScript with a .d.ts typing each export \`any\`, so it builds and runs here; the original types are gone, so code that relied on them may not typecheck (\`local check\` counts those errors as inherited while the files are as pulled). Replace ${done.length === 1 ? 'it' : 'them'} with the original source when you have it, or deploy the Function again from its source with a current CLI`)
126
+ }
127
+ if (unread.has(p)) {
128
+ const why = unread.get(p) ?? undefined
129
+ const lacking = p.missing.map((m) => project(p, m)).filter((m) => !Object.keys(p.it.files).some((f) => f.startsWith(`${m}.`) || f.startsWith(`${m}/index.`)))
130
+ if (lacking.length) {
131
+ p.it.notes.push(missingImportNote(lacking, why))
132
+ warnings.push(missingImportWarning(p.slug, lacking, why))
133
+ }
134
+ continue
135
+ }
136
+ for (const [path, why] of Object.entries(failed.get(p) ?? {})) {
137
+ p.it.notes.push(missingImportNote([path], why))
138
+ warnings.push(missingImportWarning(p.slug, [path], why))
139
+ }
140
+ }
141
+ }
@@ -32,3 +32,90 @@ export async function mirrorFunctionState(project: LocalProject, key: string, se
32
32
  }
33
33
  return state.enabled ? [] : [` switched off Function ${slug}: it is off in the workspace`]
34
34
  }
35
+
36
+ /** `GET /v1/functions/registry-status`: what the service has deployed, and what a runner has registered with the job engine. */
37
+ interface RegistryStatus {
38
+ generation: number
39
+ registeredGeneration: number | null
40
+ }
41
+
42
+ /**
43
+ * After a Function deploy: wait until the job engine can run it. A deploy
44
+ * moves the service's registry generation (the deploy answers it as
45
+ * `registryGeneration`); the Function is runnable once a runner has registered
46
+ * that generation (`registeredGeneration >= registryGeneration`, the check
47
+ * `frontera function run` makes before it sends its event). Until then a
48
+ * workflow or Agent that starts it fails with "could not find function". On
49
+ * the local runtime the runner is asked to sync at once (`POST
50
+ * /internal/reload` on its loopback port) rather than at its next poll.
51
+ * Says "registering…" once through `note` while it waits; returns a
52
+ * `warning:` line when it is still not runnable at the cap (60 s), nothing
53
+ * when it is.
54
+ */
55
+ export async function waitUntilRunnable(
56
+ serviceUrl: string,
57
+ creds: LocalCredentials,
58
+ slug: string,
59
+ opts: RunnableOptions = {},
60
+ ): Promise<string[]> {
61
+ return (await checkRunnable(serviceUrl, creds, slug, opts)).lines
62
+ }
63
+
64
+ type RunnableOptions = { note?: (line: string) => void; timeoutMs?: number; pollMs?: number; fetcher?: typeof fetch; now?: () => number; runnerUrl?: string; generation?: number }
65
+
66
+ /**
67
+ * `waitUntilRunnable`, and whether it saw the Function runnable: true when a
68
+ * runner registered its generation (a runner is alive and the job engine can
69
+ * run it), false when it still was not at the cap, null when the runtime
70
+ * could not say.
71
+ */
72
+ export async function checkRunnable(serviceUrl: string, creds: LocalCredentials, slug: string, opts: RunnableOptions = {}): Promise<{ lines: string[]; runnable: boolean | null }> {
73
+ if (!creds.workspaceKey) return { lines: [], runnable: null }
74
+ const fetcher = opts.fetcher ?? fetch
75
+ const now = opts.now ?? Date.now
76
+ const deadline = now() + (opts.timeoutMs ?? 60_000)
77
+ const read = async (): Promise<RegistryStatus | null> => {
78
+ try {
79
+ const res = await fetcher(new URL('/v1/functions/registry-status', serviceUrl), { headers: { authorization: `Bearer ${creds.workspaceKey}`, 'x-workspace-id': creds.workspaceId } })
80
+ if (!res.ok) return null
81
+ const body = (await res.json()) as { data?: RegistryStatus } & Partial<RegistryStatus>
82
+ const status = body.data ?? body
83
+ return typeof status.generation === 'number' ? { generation: status.generation, registeredGeneration: typeof status.registeredGeneration === 'number' ? status.registeredGeneration : null } : null
84
+ } catch {
85
+ return null
86
+ }
87
+ }
88
+ // The deploy's own generation when it said one; else what the service has now, which is at least that.
89
+ const caughtUp = (st: RegistryStatus) => st.registeredGeneration !== null && st.registeredGeneration >= (opts.generation ?? st.generation)
90
+ let status = await read()
91
+ // A runtime that cannot say (an older service): nothing to wait on.
92
+ if (!status) return { lines: [], runnable: null }
93
+ if (caughtUp(status)) return { lines: [], runnable: true }
94
+ opts.note?.(` Function ${slug} is registering with the job engine…`)
95
+ if (opts.runnerUrl) {
96
+ try {
97
+ await fetcher(new URL('/internal/reload', opts.runnerUrl), { method: 'POST', signal: AbortSignal.timeout(15_000) })
98
+ } catch {
99
+ // The runner's own poll registers it; the reload only makes that sooner.
100
+ }
101
+ }
102
+ const started = now()
103
+ for (;;) {
104
+ const next = await read()
105
+ if (next) status = next
106
+ if (caughtUp(status)) {
107
+ opts.note?.(` Function ${slug} is runnable (${Math.round((now() - started) / 1000)} s)`)
108
+ return { lines: [], runnable: true }
109
+ }
110
+ if (now() >= deadline) break
111
+ await new Promise((r) => setTimeout(r, opts.pollMs ?? 1000))
112
+ }
113
+ return {
114
+ runnable: false,
115
+ lines: [
116
+ status.registeredGeneration === null
117
+ ? ` warning: Function ${slug} is deployed, but no runner has registered with the job engine, so nothing can run it yet: see \`frontera local logs runner\``
118
+ : ` warning: Function ${slug} is deployed but still registering with the job engine: a workflow or Agent that starts it now fails with "could not find function"; \`frontera function run ${slug}\` waits for it`,
119
+ ],
120
+ }
121
+ }
@@ -106,10 +106,12 @@ export function providerEnv(): Record<string, string> {
106
106
  }
107
107
 
108
108
  /**
109
- * With no OpenAI or OpenRouter key, the runtime embeds with the local model:
110
- * the service's OpenAI client is pointed at it, and the organization gets a
111
- * placeholder OpenAI credential. A real key of either turns this off.
109
+ * With no OpenAI key, the service's OpenAI client is pointed at the local
110
+ * keyless embeddings, so the runtime can embed with them: with no OpenAI or
111
+ * OpenRouter key, or when the provider refuses the key stored for it (the
112
+ * organization then gets a placeholder OpenAI credential). A real OpenAI key
113
+ * turns this off, and a key the provider takes replaces their model.
112
114
  */
113
- export function keylessEmbeddings(): boolean {
114
- return !getKey('openai') && !getKey('openrouter')
115
+ export function localEmbeddingsReachable(): boolean {
116
+ return !getKey('openai')
115
117
  }