@frontera-sdk/cli 1.52.39 → 1.52.41

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 (235) 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 +49 -5
  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/draft-changes.ts +1 -1
  33. package/src/commands/agent/index-commands.ts +69 -7
  34. package/src/commands/agent/resolve.ts +2 -0
  35. package/src/commands/app/checks.ts +44 -0
  36. package/src/commands/app/deploy.ts +243 -60
  37. package/src/commands/app/deployments.ts +97 -0
  38. package/src/commands/app/dev.ts +103 -11
  39. package/src/commands/app/init.ts +36 -3
  40. package/src/commands/app/promote.ts +45 -19
  41. package/src/commands/app/pull.ts +3 -1
  42. package/src/commands/app/rollback.ts +46 -0
  43. package/src/commands/app/sdk.ts +54 -8
  44. package/src/commands/app/unpin.ts +27 -0
  45. package/src/commands/app/versions.ts +1 -1
  46. package/src/commands/automation/bindings.ts +4 -2
  47. package/src/commands/automation/build-entry.ts +4 -1
  48. package/src/commands/automation/index-commands.ts +3 -1
  49. package/src/commands/automation/pull.ts +6 -1
  50. package/src/commands/automation/run.ts +34 -9
  51. package/src/commands/blueprint/bind.ts +2 -1
  52. package/src/commands/blueprint/declarative.ts +332 -32
  53. package/src/commands/blueprint/editable.ts +3 -3
  54. package/src/commands/blueprint/get.ts +6 -2
  55. package/src/commands/blueprint/grants.ts +64 -10
  56. package/src/commands/blueprint/metric.ts +108 -0
  57. package/src/commands/blueprint/object-sets.ts +71 -0
  58. package/src/commands/blueprint/query.ts +155 -31
  59. package/src/commands/dataset/index-commands.ts +156 -7
  60. package/src/commands/knowledge/index-commands.ts +122 -20
  61. package/src/commands/local/check.ts +657 -44
  62. package/src/commands/local/data.ts +33 -12
  63. package/src/commands/local/dev.ts +876 -89
  64. package/src/commands/local/docs-mcp.ts +10 -18
  65. package/src/commands/local/index-commands.ts +300 -63
  66. package/src/commands/local/init.ts +18 -5
  67. package/src/commands/local/key.ts +0 -3
  68. package/src/commands/local/providers.ts +0 -7
  69. package/src/commands/local/remove.ts +116 -0
  70. package/src/commands/local/schema.ts +3 -4
  71. package/src/commands/local/sdk-refresh.ts +59 -0
  72. package/src/commands/local/snapshot.ts +100 -0
  73. package/src/commands/local/sync-request.ts +321 -14
  74. package/src/commands/local/sync.ts +103 -6
  75. package/src/commands/mcp/serve.ts +34 -0
  76. package/src/commands/registry.ts +54 -10
  77. package/src/commands/types.ts +29 -0
  78. package/src/commands/workflow/archive.ts +46 -0
  79. package/src/commands/workflow/get.ts +2 -1
  80. package/src/commands/workflow/index-commands.ts +5 -0
  81. package/src/commands/workflow/push.ts +49 -2
  82. package/src/commands/workflow/run.ts +65 -7
  83. package/src/commands/workflow/runs.ts +9 -6
  84. package/src/commands/workflow/webhook-test.ts +166 -0
  85. package/src/config.ts +43 -6
  86. package/src/dev-broker.ts +52 -0
  87. package/src/exit.ts +103 -4
  88. package/src/flag-help.ts +77 -17
  89. package/src/forge/claude-commands.ts +11 -6
  90. package/src/forge/claude-skills.ts +4 -4
  91. package/src/forge/touches.ts +15 -12
  92. package/src/function-outside-imports.ts +89 -0
  93. package/src/harness.ts +42 -24
  94. package/src/heal.ts +113 -2
  95. package/src/kit.ts +1 -1
  96. package/src/local/fix-words.ts +12 -0
  97. package/src/local/function-bundle-recovery.ts +775 -0
  98. package/src/local/local-actions.ts +103 -10
  99. package/src/local/local-agent-action-grants.ts +210 -0
  100. package/src/local/local-agent-checks.ts +232 -0
  101. package/src/local/local-agent-grants.ts +29 -7
  102. package/src/local/local-agent-lean-plugins.ts +68 -0
  103. package/src/local/local-agent-missing-tools.ts +68 -0
  104. package/src/local/local-agent-name.ts +27 -0
  105. package/src/local/local-app-code.ts +191 -0
  106. package/src/local/local-app-dev-files.ts +186 -0
  107. package/src/local/local-app-dev-session.ts +88 -0
  108. package/src/local/local-app-lock-install.ts +114 -0
  109. package/src/local/local-app-pulled.ts +167 -0
  110. package/src/local/local-app-restore.ts +111 -0
  111. package/src/local/local-app-runtime.ts +68 -0
  112. package/src/local/local-app-sdk-paths.ts +149 -0
  113. package/src/local/local-app-sdk.ts +509 -0
  114. package/src/local/local-applied.ts +212 -22
  115. package/src/local/local-apply-history.ts +84 -0
  116. package/src/local/local-apply-summary.ts +86 -0
  117. package/src/local/local-apply-watchdog.ts +95 -0
  118. package/src/local/local-apps.ts +181 -8
  119. package/src/local/local-blueprint-errors.ts +198 -0
  120. package/src/local/local-blueprint-outline.ts +31 -12
  121. package/src/local/local-blueprint-schemas.ts +582 -0
  122. package/src/local/local-build-inputs.ts +48 -0
  123. package/src/local/local-build-summary.ts +495 -28
  124. package/src/local/local-case-evidence.ts +120 -0
  125. package/src/local/local-case-not-run.ts +153 -0
  126. package/src/local/local-chat-apps.ts +93 -16
  127. package/src/local/local-check-inherited.ts +83 -0
  128. package/src/local/local-cities.ts +89 -0
  129. package/src/local/local-component-health.ts +119 -0
  130. package/src/local/local-control.ts +34 -7
  131. package/src/local/local-db-snapshot.ts +348 -0
  132. package/src/local/local-dev-references.ts +347 -36
  133. package/src/local/local-dev-sync.ts +141 -12
  134. package/src/local/local-docker.ts +35 -1
  135. package/src/local/local-docs-search.ts +1 -1
  136. package/src/local/local-docs.ts +281 -2
  137. package/src/local/local-empty-workflows.ts +92 -0
  138. package/src/local/local-env.ts +16 -1
  139. package/src/local/local-file-checks.ts +104 -0
  140. package/src/local/local-function-ids.ts +141 -0
  141. package/src/local/local-function-recovery.ts +141 -0
  142. package/src/local/local-function-state.ts +87 -0
  143. package/src/local/local-keys.ts +7 -5
  144. package/src/local/local-knowledge-documents.ts +1 -1
  145. package/src/local/local-knowledge-ingestion.ts +225 -0
  146. package/src/local/local-knowledge-uploads.ts +217 -0
  147. package/src/local/local-lessons.ts +1 -1
  148. package/src/local/local-library.ts +1 -1
  149. package/src/local/local-media-placeholders.ts +182 -0
  150. package/src/local/local-media-sets.ts +90 -10
  151. package/src/local/local-model-parameters.ts +89 -0
  152. package/src/local/local-object-sets.ts +18 -6
  153. package/src/local/local-operating.ts +35 -1
  154. package/src/local/local-org-settings.ts +206 -8
  155. package/src/local/local-orphans.ts +321 -0
  156. package/src/local/local-personas.ts +296 -0
  157. package/src/local/local-portability.ts +143 -18
  158. package/src/local/local-ports.ts +99 -7
  159. package/src/local/local-project-notices.ts +234 -0
  160. package/src/local/local-project-secrets.ts +133 -0
  161. package/src/local/local-project.ts +12 -0
  162. package/src/local/local-provider-refusal.ts +66 -0
  163. package/src/local/local-request-evidence.ts +112 -0
  164. package/src/local/local-runtime-health.ts +123 -0
  165. package/src/local/local-runtime-history.ts +50 -0
  166. package/src/local/local-runtime-target.ts +42 -0
  167. package/src/local/local-sample-data.ts +141 -0
  168. package/src/local/local-sample-remake.ts +138 -0
  169. package/src/local/local-scaffold.ts +336 -53
  170. package/src/local/local-schema-check.ts +148 -0
  171. package/src/local/local-schemas.ts +742 -25
  172. package/src/local/local-secret-carry.ts +107 -0
  173. package/src/local/local-secrets.ts +41 -11
  174. package/src/local/local-seed.ts +102 -26
  175. package/src/local/local-stand-ins.ts +3 -3
  176. package/src/local/local-state.ts +43 -2
  177. package/src/local/local-studio-server.ts +170 -7
  178. package/src/local/local-supervisor.ts +44 -6
  179. package/src/local/local-sync-merge.ts +1 -1
  180. package/src/local/local-sync-names.ts +2 -0
  181. package/src/local/local-sync-plan.ts +19 -8
  182. package/src/local/local-sync-push.ts +15 -12
  183. package/src/local/local-sync-refs.ts +49 -3
  184. package/src/local/local-sync-remote.ts +349 -38
  185. package/src/local/local-sync-snapshot.ts +18 -13
  186. package/src/local/local-synthetic-data.ts +2116 -99
  187. package/src/local/local-test-baseline.ts +113 -0
  188. package/src/local/local-test-cases.ts +76 -1
  189. package/src/local/local-test-runner.ts +582 -91
  190. package/src/local/local-test-runs.ts +13 -5
  191. package/src/local/local-vendored-sdk.ts +183 -0
  192. package/src/local/local-workflow-state.ts +16 -3
  193. package/src/local/local-workflow-tests.ts +482 -0
  194. package/src/local/local-workspace-grants.ts +81 -0
  195. package/src/main.ts +50 -9
  196. package/src/mcp/a11y-scan.ts +55 -0
  197. package/src/mcp/browser/ax-snapshot.ts +181 -0
  198. package/src/mcp/browser/credentials.ts +78 -0
  199. package/src/mcp/browser/frames.ts +336 -0
  200. package/src/mcp/browser/headless-session.ts +413 -0
  201. package/src/mcp/browser/headless-tab.ts +575 -0
  202. package/src/mcp/browser/keys.ts +70 -0
  203. package/src/mcp/browser/page-scripts.ts +129 -0
  204. package/src/mcp/browser/wait.ts +73 -0
  205. package/src/mcp/forge-link.ts +126 -0
  206. package/src/mcp/headless.ts +199 -0
  207. package/src/mcp/mcp-apps.ts +435 -0
  208. package/src/mcp/mcp-chat.ts +226 -0
  209. package/src/mcp/mcp-cli.ts +53 -0
  210. package/src/mcp/mcp-protocol.ts +122 -0
  211. package/src/mcp/mcp-runtime.ts +113 -0
  212. package/src/mcp/mcp-server.ts +108 -0
  213. package/src/mcp/mcp-traces.ts +409 -0
  214. package/src/mcp/page-observation.ts +193 -0
  215. package/src/mcp/tools/agent-tools.ts +700 -0
  216. package/src/mcp/tools/app-tools.ts +241 -0
  217. package/src/mcp/tools/browser-tools.ts +310 -0
  218. package/src/mcp/tools/docs-tools.ts +32 -0
  219. package/src/mcp/tools/project-tools.ts +205 -0
  220. package/src/mcp/tools/test-tools.ts +339 -0
  221. package/src/mcp/tools/workflow-tools.ts +611 -0
  222. package/src/output.ts +1 -1
  223. package/src/pack.ts +8 -3
  224. package/src/packaging.ts +1 -1
  225. package/src/project-bootstrap.ts +107 -4
  226. package/src/project-context.ts +18 -1
  227. package/src/project.ts +27 -2
  228. package/src/provenance.ts +25 -0
  229. package/src/render-evidence.ts +65 -7
  230. package/src/scopes.ts +1 -1
  231. package/src/template.ts +14 -8
  232. package/src/templates/next-app-files.ts +35 -6
  233. package/src/templates/next-skills.ts +61 -27
  234. package/src/vendor/kit-assets.json +6 -6
  235. package/src/vendor/sdk-sources.json +17 -14
@@ -1,7 +1,9 @@
1
- import { existsSync, readdirSync, readFileSync } from 'node:fs'
2
- import { join } from 'node:path'
1
+ import { existsSync, lstatSync, readdirSync, readFileSync, statSync } from 'node:fs'
2
+ import { basename, join, relative } from 'node:path'
3
3
 
4
4
  import type { RemoteItem } from './local-sync-plan'
5
+ import { readDevSessionState } from './local-app-dev-session'
6
+ import { isAlive } from './local-state'
5
7
 
6
8
  /**
7
9
  * Custom Apps: the workspace's code Apps (a Chat App is `chat-apps/`). A
@@ -11,9 +13,10 @@ import type { RemoteItem } from './local-sync-plan'
11
13
  * with its switch. `local dev` makes the App on the local runtime and binds
12
14
  * those Functions, so the App's calls to them are allowed here as there.
13
15
  *
14
- * Its code is its own project (a folder `frontera app pull` fills and
15
- * `frontera app deploy` ships), and is not brought: the App is saved here
16
- * without it, which its portability says (Adapted). Who may open it, its
16
+ * Its code is its own project: a pull brings the deployed version's source
17
+ * into `apps/<slug>/` (`local-app-code.ts`) unless `--no-app-code` or the
18
+ * folder is there, and `local dev` deploys it here on its own; its
19
+ * portability says so (Adapted). Who may open it, its
17
20
  * sign-in (audiences, roles, keys) and its addresses are the platform's:
18
21
  * people and deployment (sync v2 design §8). Never proposed back.
19
22
  */
@@ -45,7 +48,7 @@ export interface AppFile {
45
48
  export const appPath = (slug: string) => `apps/${slug}.json`
46
49
 
47
50
  /** Why an App is Adapted here, as the build and the Pull dialog say it. */
48
- export const APP_WITHOUT_CODE = 'Saved here with the Functions it may start, without its code: an App’s code is its own project, deployed to this runtime on its own'
51
+ export const APP_WITHOUT_CODE = 'Saved here with the Functions it may start; its code is its own project in apps/<slug>/, which a pull brings unless --no-app-code, where `frontera app dev` runs it against this runtime and `frontera app deploy` deploys it here'
49
52
 
50
53
  const strings = (v: unknown): string[] | undefined => (Array.isArray(v) && v.every((x) => typeof x === 'string') && v.length ? (v as string[]) : undefined)
51
54
 
@@ -71,7 +74,7 @@ export function readApps(rows: AppRow[], wanted: (ref: string) => boolean): Remo
71
74
  if (!wanted(ref)) continue
72
75
  const doc = appDocument(row)
73
76
  const notes = [
74
- row.version ? `Its code (version ${row.version}) is its own project and stays on the platform; this file is what the App is and the Functions it may start` : 'Never deployed in the workspace; this file is what the App is and the Functions it may start',
77
+ row.version ? `Its code (version ${row.version}) is its own project: a pull brings it into apps/${row.slug}/ unless --no-app-code or that folder is there; this file is what the App is and the Functions it may start` : 'Never deployed in the workspace; this file is what the App is and the Functions it may start',
75
78
  row.hosting === 'external' ? 'Hosted on its own domain: its sign-in, roles and keys stay on the platform' : 'Who may open it and its addresses stay on the platform',
76
79
  ]
77
80
  out.push({ ref, kind: 'app', name: row.slug, title: row.name, files: { [appPath(row.slug)]: `${JSON.stringify(doc, null, 2)}\n` }, unresolved: [], notes, requires: doc.functions.map((f) => f.function), id: row.id, ...(row.version ? { version: `v${row.version}` } : {}) })
@@ -151,6 +154,176 @@ export async function applyApp(root: string, slug: string, call: Call, provides:
151
154
  const gone = await call('DELETE', `/v1/functions/${encodeURIComponent(fn)}/app-bindings/${encodeURIComponent(slug)}`)
152
155
  if (gone.status >= 400 && gone.status !== 404) lines.push(` warning: Function ${fn} could not be unbound from it (${errorOf(gone)})`)
153
156
  }
154
- lines.unshift(` saved App ${name}${named.size ? ` (may start ${[...named].sort().join(', ')})` : ''}`)
157
+ lines.unshift(` saved App ${name}${named.size ? ` (may start ${[...named].sort((a, b) => (String(a) < String(b) ? -1 : String(a) > String(b) ? 1 : 0)).join(', ')})` : ''}`)
155
158
  return { ok: true, output: lines.join('\n') }
156
159
  }
160
+
161
+ // ── A Custom App's code, in the project ─────────────────────────────────────
162
+
163
+ /** Folders an App's code is never in: dependencies, build output, the project's state and its other kinds. */
164
+ const NOT_APP_DIRS = new Set(['node_modules', '.git', '.frontera', '.next', 'dist', 'out', 'build', 'vendor', 'fixtures', 'datasets', 'blueprint', 'agents', 'workflows', 'functions', 'knowledge', 'skills', 'themes', 'mcp', 'chat-apps'])
165
+
166
+ export function readPackage(dir: string): { scripts?: Record<string, string>; frontera?: unknown } | null {
167
+ try {
168
+ return JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')) as { scripts?: Record<string, string>; frontera?: unknown }
169
+ } catch {
170
+ return null
171
+ }
172
+ }
173
+
174
+ /** A folder that is a Custom App's code: its manifest (`frontera.config.json`, or `frontera` in package.json) beside a package.json. */
175
+ function isAppFolder(dir: string): boolean {
176
+ if (!existsSync(join(dir, 'package.json'))) return false
177
+ if (existsSync(join(dir, 'frontera.config.json'))) return true
178
+ return !!readPackage(dir)?.frontera
179
+ }
180
+
181
+ /**
182
+ * Each Custom App's code folder, relative to the project: apps/<name>/ with a
183
+ * package.json, and any other folder an App was made in (`frontera app init
184
+ * <name>` at the project's root makes ./<name>/), found by its manifest. The
185
+ * project's own root is not one.
186
+ */
187
+ export function customAppFolders(root: string): string[] {
188
+ const out: string[] = []
189
+ const walk = (dir: string, depth: number) => {
190
+ let names: string[]
191
+ try {
192
+ names = readdirSync(dir).sort()
193
+ } catch {
194
+ return
195
+ }
196
+ for (const name of names) {
197
+ if (name.startsWith('.') || NOT_APP_DIRS.has(name)) continue
198
+ const path = join(dir, name)
199
+ try {
200
+ if (!lstatSync(path).isDirectory()) continue
201
+ } catch {
202
+ continue
203
+ }
204
+ // apps/<name>/ with a package.json is an App's code, manifest or not, as it always was.
205
+ if (isAppFolder(path) || (dir === join(root, 'apps') && existsSync(join(path, 'package.json')))) {
206
+ out.push(relative(root, path))
207
+ continue
208
+ }
209
+ if (depth < 2) walk(path, depth + 1)
210
+ }
211
+ }
212
+ walk(root, 0)
213
+ return out
214
+ }
215
+
216
+ /**
217
+ * Where each Custom App's code is, as Studio says how to work on it: `folder`
218
+ * (its code folder, where `frontera app dev` runs it), `not-pulled` (the
219
+ * workspace deployed it, but its code isn't in the project: a pull brings
220
+ * it), or `none` (its file only describes it: it was never deployed, so no
221
+ * code exists, and `frontera app init` starts one). By the App's slug, for
222
+ * every apps/<slug>.json and every code folder.
223
+ */
224
+ export type AppCodeState = { slug: string; code: 'folder'; dir: string } | { slug: string; code: 'not-pulled'; version: string } | { slug: string; code: 'none' }
225
+
226
+ export function appCodeStates(root: string): AppCodeState[] {
227
+ const folders = new Map(customAppFolders(root).map((dir) => [basename(dir), dir] as const))
228
+ const described = existsSync(join(root, 'apps')) ? readdirSync(join(root, 'apps')).filter((f) => f.endsWith('.json')).map((f) => f.slice(0, -'.json'.length)) : []
229
+ const slugs = [...new Set([...described, ...folders.keys()])].sort()
230
+ return slugs.map((slug): AppCodeState => {
231
+ const dir = folders.get(slug)
232
+ if (dir) return { slug, code: 'folder', dir }
233
+ let version: unknown
234
+ try {
235
+ version = (JSON.parse(readFileSync(join(root, appPath(slug)), 'utf8')) as { version?: unknown }).version
236
+ } catch {
237
+ version = undefined
238
+ }
239
+ return typeof version === 'string' && version ? { slug, code: 'not-pulled', version } : { slug, code: 'none' }
240
+ })
241
+ }
242
+
243
+ /** What the project knows about one Custom App's code: its last deployment here and its dev server. */
244
+ export interface CustomAppFolder {
245
+ /** Its folder's name: the row's name (`app/<name>`). */
246
+ name: string
247
+ /** Its folder, relative to the project. */
248
+ dir: string
249
+ /** The last `frontera app deploy` from this folder, when it made one: its id and when. */
250
+ deployment: { id: string; at: string | null } | null
251
+ /** The version its code was pulled at (a pull of the App, or `frontera app pull`), when it was: not a deployment here. */
252
+ pulled: { version: string; at: string | null } | null
253
+ /** Its code changed after that deployment. */
254
+ changedSinceDeploy: boolean
255
+ /** Its build is in the folder (`out/`, what `frontera app deploy` uploads), deployed or not. */
256
+ built: boolean
257
+ /** The dev server `frontera app dev` (or `app_dev`) runs for it, while it runs. */
258
+ devServer: string | null
259
+ /**
260
+ * A deployment from here that went with a database restore (`frontera local
261
+ * snapshot restore`, a `local check` rollback): the restored database does
262
+ * not have it, and the next `frontera local dev` deploys the App again.
263
+ */
264
+ away?: { deployment: string; restoredAt: string }
265
+ /** The running dev server could not mint the browser's session the last time it tried: its words. */
266
+ devSessionError?: string
267
+ }
268
+
269
+ function newestSource(dir: string): number {
270
+ let newest = 0
271
+ const walk = (at: string, depth: number) => {
272
+ let names: string[]
273
+ try {
274
+ names = readdirSync(at)
275
+ } catch {
276
+ return
277
+ }
278
+ for (const n of names) {
279
+ if (n.startsWith('.') || ['node_modules', 'out', 'dist', '.next', 'build'].includes(n)) continue
280
+ const path = join(at, n)
281
+ try {
282
+ const st = statSync(path)
283
+ if (st.isDirectory()) {
284
+ if (depth < 8) walk(path, depth + 1)
285
+ } else if (st.mtimeMs > newest && !/\.tsbuildinfo$/.test(n)) newest = st.mtimeMs
286
+ } catch {
287
+ // Gone while reading.
288
+ }
289
+ }
290
+ }
291
+ walk(dir, 0)
292
+ return newest
293
+ }
294
+
295
+ export function customAppFolder(root: string, dir: string): CustomAppFolder {
296
+ const name = basename(dir)
297
+ const statePath = join(root, dir, '.frontera', 'state.json')
298
+ let deployment: CustomAppFolder['deployment'] = null
299
+ let pulled: CustomAppFolder['pulled'] = null
300
+ let changedSinceDeploy = false
301
+ let away: CustomAppFolder['away']
302
+ try {
303
+ const state = JSON.parse(readFileSync(statePath, 'utf8')) as { parentVersion?: unknown; pulledVersion?: unknown; restoredAway?: { deployment?: unknown; restoredAt?: unknown } }
304
+ const at = statSync(statePath).mtimeMs
305
+ const r = state.restoredAway
306
+ if (r && typeof r.deployment === 'string' && typeof r.restoredAt === 'string') away = { deployment: r.deployment, restoredAt: r.restoredAt }
307
+ if (typeof state.parentVersion === 'string' && state.parentVersion) {
308
+ deployment = { id: state.parentVersion, at: new Date(at).toISOString() }
309
+ changedSinceDeploy = newestSource(join(root, dir)) > at + 1000
310
+ }
311
+ if (typeof state.pulledVersion === 'string' && state.pulledVersion) pulled = { version: state.pulledVersion, at: new Date(at).toISOString() }
312
+ } catch {
313
+ // Never deployed from here.
314
+ }
315
+ let devServer: string | null = null
316
+ let devSessionError: string | undefined
317
+ try {
318
+ const dev = JSON.parse(readFileSync(join(root, '.frontera', 'local', 'app-dev', `${name}.json`), 'utf8')) as { pid?: unknown; url?: unknown; startedAt?: unknown }
319
+ if (typeof dev.url === 'string' && typeof dev.pid === 'number' && isAlive(dev.pid)) {
320
+ devServer = dev.url
321
+ // This server's own last session mint, not one an earlier server recorded.
322
+ const session = readDevSessionState(join(root, '.frontera', 'local'), name)
323
+ if (session && !session.ok && (typeof dev.startedAt !== 'string' || session.at >= dev.startedAt)) devSessionError = session.error ?? 'its session request failed'
324
+ }
325
+ } catch {
326
+ // Not running.
327
+ }
328
+ return { name, dir, deployment, pulled, changedSinceDeploy, built: existsSync(join(root, dir, 'out')), devServer, ...(away ? { away } : {}), ...(devSessionError ? { devSessionError } : {}) }
329
+ }
@@ -0,0 +1,198 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+
4
+ import type { ActionBuild } from './local-actions'
5
+ import { ACTIVE_CATALOG } from './local-blueprint-schemas'
6
+
7
+ /**
8
+ * A Blueprint failure, said against the project's files.
9
+ *
10
+ * The runtime names a problem by where it sits in the draft definition it
11
+ * holds (`actions.1.concurrency.strategy`), which says nothing to whoever
12
+ * edits `blueprint/actions/resetLine.yaml`; and `blueprint apply` prints the
13
+ * same issue in its message and again in its hint. Here each indexed path
14
+ * becomes the file it came from and the field inside it, and a line that says
15
+ * what another already says is dropped.
16
+ */
17
+
18
+ /** The draft definition's arrays, and the `blueprint/` folder each one's files live in. */
19
+ const COLLECTIONS: Record<string, string> = {
20
+ actions: 'actions',
21
+ objects: 'object-types',
22
+ objectTypes: 'object-types',
23
+ relationships: 'link-types',
24
+ linkTypes: 'link-types',
25
+ metrics: 'metrics',
26
+ sharedProperties: 'shared-fields',
27
+ semanticTypes: 'semantic-types',
28
+ }
29
+
30
+ const COLLECTION = Object.keys(COLLECTIONS).join('|')
31
+
32
+ /** `actions.1.x`, `actions[1].x`, `/actions/1/x`: a collection, an index, then the field path. */
33
+ const INDEXED = new RegExp(`(?<![A-Za-z0-9_])/?(${COLLECTION})(?:\\.|\\[|/)(\\d+)\\]?((?:(?:\\.|/)[A-Za-z0-9_]+|\\[\\d+\\])*)`, 'g')
34
+
35
+ /** The file a Blueprint artifact lives in, as the project holds it: `blueprint/actions/resetLine.yaml`. */
36
+ export function blueprintFile(root: string, folder: string, apiName: string): string {
37
+ for (const ext of ['yaml', 'yml', 'json']) {
38
+ const rel = join('blueprint', folder, `${apiName}.${ext}`)
39
+ if (existsSync(join(root, rel))) return rel
40
+ }
41
+ return join('blueprint', folder, `${apiName}.yaml`)
42
+ }
43
+
44
+ /** `/concurrency/strategy` or `.inputs[0].valueType` → `concurrency.strategy`, `inputs.0.valueType`. */
45
+ function fieldPath(rest: string): string {
46
+ return rest
47
+ .replace(/\[(\d+)\]/g, '.$1')
48
+ .split(/[./]/)
49
+ .filter(Boolean)
50
+ .join('.')
51
+ }
52
+
53
+ /**
54
+ * Every indexed path in the text, named by the file it came from: the
55
+ * definition is the runtime's draft (`GET /v1/blueprint/lifecycle`), whose
56
+ * arrays the indices count into. A path whose index the definition does not
57
+ * have is left as it is.
58
+ */
59
+ export function nameIndexedPaths(text: string, definition: unknown, root: string): string {
60
+ const def = (definition && typeof definition === 'object' ? definition : {}) as Record<string, unknown>
61
+ return text.replace(INDEXED, (whole, collection: string, index: string, rest: string) => {
62
+ const list = def[collection]
63
+ const entry = Array.isArray(list) ? (list[Number(index)] as { apiName?: unknown } | undefined) : undefined
64
+ const apiName = typeof entry?.apiName === 'string' ? entry.apiName : null
65
+ if (!apiName) return whole
66
+ const field = fieldPath(rest)
67
+ const file = blueprintFile(root, COLLECTIONS[collection]!, apiName)
68
+ return field ? `${file} (${field})` : file
69
+ })
70
+ }
71
+
72
+ /** Whether the text names a definition array by index, so it is worth reading the draft to name it. */
73
+ export function hasIndexedPaths(text: string): boolean {
74
+ INDEXED.lastIndex = 0
75
+ const found = INDEXED.test(text)
76
+ INDEXED.lastIndex = 0
77
+ return found
78
+ }
79
+
80
+ /** The words of a line, for telling two lines that say the same thing apart from two that do not. */
81
+ function gist(line: string): string {
82
+ return line
83
+ .replace(/^\s*(error|hint|warning)(\s*\[[^\]]*\])?:\s*/i, '')
84
+ .toLowerCase()
85
+ .replace(/[^a-z0-9]+/g, ' ')
86
+ .trim()
87
+ }
88
+
89
+ /**
90
+ * Lines with each thing said once: a line whose words another line already
91
+ * holds is dropped (the hint that repeats the message), and of two that
92
+ * overlap, the longer one stays where the first one was.
93
+ */
94
+ export function dedupeLines(lines: string[]): string[] {
95
+ const kept: Array<{ line: string; gist: string }> = []
96
+ for (const line of lines) {
97
+ const g = gist(line)
98
+ if (!g) continue
99
+ if (kept.some((k) => k.gist.includes(g))) continue
100
+ const shorter = kept.findIndex((k) => g.includes(k.gist))
101
+ if (shorter >= 0) {
102
+ kept[shorter] = { line, gist: g }
103
+ // A third line the new one also covers.
104
+ for (let i = kept.length - 1; i > shorter; i--) if (g.includes(kept[i]!.gist)) kept.splice(i, 1)
105
+ continue
106
+ }
107
+ kept.push({ line, gist: g })
108
+ }
109
+ return kept.map((k) => k.line)
110
+ }
111
+
112
+ /**
113
+ * The governed Actions that did not deploy, as the failure `local dev` reports:
114
+ * one line per Action, naming its file, then each failed check under it.
115
+ * Empty when every Action that runs here deployed.
116
+ */
117
+ export function actionFailureLines(actions: Record<string, ActionBuild>, root: string): string[] {
118
+ const out: string[] = []
119
+ for (const [name, a] of Object.entries(actions)) {
120
+ if (a.status !== 'failed') continue
121
+ out.push(`governed Action ${name} (${blueprintFile(root, 'actions', name)}) didn’t deploy: ${a.reason ?? 'no reason given'}`)
122
+ for (const f of a.findings ?? []) out.push(` ✕ ${f}`)
123
+ }
124
+ return out
125
+ }
126
+
127
+ /** The service's refusal of an Action's source system by a name the draft lacks. */
128
+ const UNKNOWN_SOURCE_SYSTEM = /authoritySourceSystem names a source system "([^"]*)", which is not on the draft/
129
+
130
+ /**
131
+ * What to write instead when an Action names a source system the draft does
132
+ * not have: source systems have no file or command, so the one an Action that
133
+ * writes Blueprint records can name is `active-catalog`, which the service
134
+ * adds with the first object type. Null when the text is about something else.
135
+ */
136
+ export function sourceSystemHint(text: string): string | null {
137
+ const m = UNKNOWN_SOURCE_SYSTEM.exec(text)
138
+ if (!m || m[1] === ACTIVE_CATALOG) {
139
+ return m ? `\`${ACTIVE_CATALOG}\` is added to the draft with its first object type: apply an object type before (or with) the Action.` : null
140
+ }
141
+ return `An Action that writes the Blueprint's own records names \`effect.authoritySourceSystem: ${ACTIVE_CATALOG}\`, or leaves the key out (source systems have no file or command of their own); "${m[1]}" is not one.`
142
+ }
143
+
144
+ /** `blueprint apply`'s line naming the step the runtime refused: `Stopped at: create object-type Receipt`. */
145
+ const STOPPED_AT = /^\s*Stopped at: (create|update|delete) (semantic-type|shared-field|object-type|link-type|metric|action) ([A-Za-z][A-Za-z0-9_]*)\s*$/m
146
+
147
+ /**
148
+ * The Blueprint artifact a failed `blueprint apply` stopped at, as
149
+ * `kind:apiName` (`object-type:Receipt`), with the runtime's reason: the
150
+ * line after the step's. Null when the output names no step (a failure
151
+ * before any write, or a CLI that does not say it), or when the draft moved
152
+ * under the apply (a conflict is not the file's fault).
153
+ */
154
+ export function refusedArtifact(output: string): { key: string; reason: string } | null {
155
+ const m = STOPPED_AT.exec(output)
156
+ if (!m) return null
157
+ // Someone else changed the draft first (another apply, a person in Blueprint): the file is not
158
+ // what was refused, so nothing is left off for it.
159
+ if (/error \[(CONFLICT|DRAFT_CONFLICT|VALIDATION_STALE)\]/.test(output)) return null
160
+ const after = output.slice(m.index + m[0].length).split('\n').map((l) => l.trim()).filter(Boolean)
161
+ const at = after.findIndex((l) => !/^(hint|warning):/i.test(l) && !/^frontera\s/.test(l))
162
+ const reason = at >= 0 ? after[at]!.replace(/^error(\s*\[[^\]]*\])?:\s*/i, '') : 'the runtime refused it'
163
+ // The specifics travel in the error's hint (`blueprint apply` prints the service's details there:
164
+ // `businessOutcomes.0.expectedChanges.4.value — An optional input …`): said with the reason, so
165
+ // `local dev` and `status` never stop at "validation failed". A command to run is not a reason.
166
+ const specifics = at >= 0
167
+ ? after.slice(at + 1)
168
+ .map((l) => l.replace(/^hint:\s*/i, ''))
169
+ .filter((l) => l && !/^frontera\s/.test(l) && !/^`?frontera\s/.test(l) && !/^warning:/i.test(l) && !/^(Applied before failing|Nothing was applied)/.test(l))
170
+ : []
171
+ const said = dedupeLines([reason, ...specifics])
172
+ // A detail the reason already carries (a service that names the issue in its message too) is said once.
173
+ const gistOf = (l: string) => (l.includes(' — ') ? l.slice(l.lastIndexOf(' — ') + 3) : l).replace(/[.]$/, '').trim()
174
+ const head = said[0] ?? reason
175
+ const fresh = said.slice(1).filter((l) => !head.includes(gistOf(l)))
176
+ const joined = fresh.length ? `${head.replace(/[.:]\s*$/, '')}: ${fresh.map((l) => l.replace(/[.]$/, '')).join('; ')}` : head
177
+ return { key: `${m[2]}:${m[3]}`, reason: joined }
178
+ }
179
+
180
+ /** A `✕ <folder>/<name>.yaml: <why>` line `blueprint plan`/`apply` prints for a rule an Action or metric file breaks. */
181
+ const RULE_LINE = /^\s*✕\s+(?:\S*\/)?(actions|metrics)\/([A-Za-z][A-Za-z0-9_]*)\.(?:ya?ml|json):\s*(.+?)\s*$/gm
182
+
183
+ /**
184
+ * The Action and metric files `blueprint apply` refused before its first write
185
+ * (a broken rule: `actions/x.yaml: … optional with no default …`), each as
186
+ * `kind:apiName` with its reasons: `local dev` leaves each off and applies the
187
+ * rest, as it does for a step the runtime refused. Empty when the output is
188
+ * not such a refusal.
189
+ */
190
+ export function refusedByRules(output: string): Array<{ key: string; reason: string }> {
191
+ if (!/error \[VALIDATION_ERROR\]/.test(output) || !/so nothing was (planned or )?applied/.test(output)) return []
192
+ const reasons = new Map<string, string[]>()
193
+ for (const m of output.matchAll(RULE_LINE)) {
194
+ const key = `${m[1] === 'actions' ? 'action' : 'metric'}:${m[2]}`
195
+ reasons.set(key, [...(reasons.get(key) ?? []), m[3]!])
196
+ }
197
+ return [...reasons].map(([key, why]) => ({ key, reason: why.join('; ') }))
198
+ }
@@ -199,12 +199,20 @@ const ACTION_LABEL: Record<ActionLocalStatus, string> = {
199
199
  'not-deployed': 'Not deployed yet',
200
200
  }
201
201
 
202
+ /** What the runtime said when it refused an Action's file, without the build record's lead-in. */
203
+ export function refusedReason(reason: string | undefined): string {
204
+ const words = (reason ?? 'the local runtime refused it').replace(/^the local runtime refused it: /, '')
205
+ return words.charAt(0).toUpperCase() + words.slice(1)
206
+ }
207
+
202
208
  /**
203
209
  * How the local runtime holds one Action: what the last Blueprint build
204
210
  * recorded for the files as they are, or, before one, what it will do.
205
211
  */
206
212
  export function actionLocal(doc: Doc, built: BuildEntry['actions'] | null): OutlineAction['local'] {
207
213
  const recorded = built?.[doc.apiName as string]
214
+ // Refused by the runtime (its own file is wrong), not left off by design: a failure to fix, with the runtime's words.
215
+ if (recorded?.status === 'left-off' && recorded.refused) return { status: 'failed', label: 'Refused by the runtime', reason: refusedReason(recorded.reason) }
208
216
  if (recorded) return { status: recorded.status, label: ACTION_LABEL[recorded.status], reason: recorded.reason ?? null }
209
217
  const fit = actionFit(doc)
210
218
  return fit.runsLocally ? { status: 'not-deployed', label: ACTION_LABEL['not-deployed'], reason: null } : { status: 'switched-off', label: ACTION_LABEL['switched-off'], reason: fit.reason ?? null }
@@ -403,29 +411,34 @@ export interface BlueprintBuildState {
403
411
  applied: boolean
404
412
  /** The last Blueprint build failed for these files: its error. */
405
413
  failed?: string | null
414
+ /** Each object type as the last build left it, when it was for these files: a type the runtime refused says so. */
415
+ objects?: BuildEntry['objects'] | null
406
416
  }
407
417
 
408
418
  /**
409
419
  * The one thing worth saying about an object type on this runtime, most
410
420
  * important first: platform only > the Blueprint failed to build > not
411
- * applied yet > adapted (an Action that does not run here, a calculated field
412
- * off, every row read) > built. `state` (or `true`) says the runtime holds the
421
+ * applied yet > adapted (an Action switched off here, a calculated field
422
+ * off, every row read) > built. An Action that didn't deploy is its own
423
+ * row, not its type's. `state` (or `true`) says the runtime holds the
413
424
  * project's current Blueprint.
414
425
  */
415
- export function objectStatus(o: Pick<ObjectOutline, 'fields' | 'actions' | 'local'>, state: boolean | BlueprintBuildState): ObjectStatus {
426
+ export function objectStatus(o: Pick<ObjectOutline, 'fields' | 'actions' | 'local'> & { apiName?: string }, state: boolean | BlueprintBuildState): ObjectStatus {
416
427
  const s: BlueprintBuildState = typeof state === 'boolean' ? { applied: state } : state
417
428
  if (o.local.leftOff) return { kind: 'platform-only', label: 'Platform only' }
418
429
  if (s.failed) return { kind: 'failed', label: 'Failed', reason: s.failed }
430
+ // The runtime refused this type alone: it is left off, and the rest of the Blueprint built.
431
+ const refused = o.apiName ? s.objects?.[o.apiName]?.refused : undefined
432
+ if (refused) return { kind: 'failed', label: 'Left off: the runtime refused it', reason: refused }
419
433
  if (!s.applied) return { kind: 'not-applied', label: 'Not applied yet' }
420
- const idle = o.actions.filter((a) => a.local.status === 'switched-off' || a.local.status === 'failed')
434
+ // A governed Action that didn't deploy is a failure of its own (`action/<name>`, a row of the
435
+ // build), so the type it acts on is not counted as failed a second time.
436
+ const idle = o.actions.filter((a) => a.local.status === 'switched-off')
421
437
  if (idle.length) {
422
- const failed = idle.filter((a) => a.local.status === 'failed')
423
- const label = failed.length
424
- ? failed.length === 1 ? 'An action didn’t deploy' : `${failed.length} actions didn’t deploy`
425
- : idle.length === 1 ? '1 action switched off here' : `${idle.length} actions switched off here`
438
+ const label = idle.length === 1 ? '1 action switched off here' : `${idle.length} actions switched off here`
426
439
  return { kind: 'adapted', label, reason: idle.map((a) => `${a.displayName}: ${a.local.reason ?? a.local.label}`).join('; ') }
427
440
  }
428
- if (o.local.mediaStub) return { kind: 'stubbed', label: 'Stubbed: no files here', reason: `Its files stay on the platform: an empty Media Set, ${o.local.mediaStub}, stands in for them here` }
441
+ if (o.local.mediaStub) return { kind: 'stubbed', label: 'Stubbed: placeholder files', reason: `Its files stay on the platform: a stand-in Media Set, ${o.local.mediaStub}, holds placeholder files for them here` }
429
442
  if (o.local.calculated.length) {
430
443
  const names = o.local.calculated.map((api) => o.fields.find((f) => f.apiName === api)?.displayName ?? api)
431
444
  return { kind: 'adapted', label: names.length === 1 ? `Adapted: ${inline(names[0]!)} off` : `Adapted: ${names.length} fields off` }
@@ -445,14 +458,20 @@ export function localOrganizationId(root: string): string | null {
445
458
  }
446
459
 
447
460
  /** The Blueprint's build for the files as they are: applied or not, failed or not, and its Actions. */
448
- export function blueprintBuild(root: string): BlueprintBuildState & { actions: BuildEntry['actions'] | null } {
461
+ export function blueprintBuild(root: string): BlueprintBuildState & { actions: BuildEntry['actions'] | null; parts: BuildEntry['parts'] | null; refusedBefore: BuildEntry['actions'] | null } {
449
462
  const project = { root, id: '', stateDir: join(root, '.frontera', 'local') }
450
463
  const org = localOrganizationId(root)
451
464
  const entry = loadBuild(project, org).blueprint
452
465
  // The record speaks only for the files it was made from.
453
466
  const current = !!entry && entry.hash === contentHash(project, { kind: 'blueprint', key: 'blueprint' })
454
- const failed = current && entry.status === 'failed' ? (entry.error ?? 'The Blueprint didn’t apply') : null
455
- return { applied: blueprintApplyState(project, org) === 'applied', failed, actions: current && !failed ? (entry.actions ?? null) : null }
467
+ // Published, with a governed Action that didn't deploy: the types are on the runtime, and
468
+ // that Action's object type is what failed (`objectStatus`), not every type.
469
+ const published = current && entry.status === 'failed' && !!entry.actions && Object.values(entry.actions).some((a) => a.status === 'failed')
470
+ const failed = current && entry.status === 'failed' && !published ? (entry.error ?? 'The Blueprint didn’t apply') : null
471
+ // The Actions the last build couldn't deploy, kept when a Blueprint file changed since: a refusal stays a
472
+ // failure until an apply says otherwise, as any item's does (`buildView`), not "not applied" while unapplied.
473
+ const refusedBefore = entry && !current && entry.actions ? Object.fromEntries(Object.entries(entry.actions).filter(([, a]) => a.status === 'failed' || (a.status === 'left-off' && a.refused))) : null
474
+ return { applied: published || blueprintApplyState(project, org) === 'applied', failed, actions: current && !failed ? (entry.actions ?? null) : null, objects: current && !failed ? (entry.objects ?? null) : null, parts: current && !failed ? (entry.parts ?? null) : null, refusedBefore: refusedBefore && Object.keys(refusedBefore).length ? refusedBefore : null }
456
475
  }
457
476
 
458
477
  /** The outline of a project folder: its files, its local datasets and what names its Actions. */