@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
@@ -1,9 +1,23 @@
1
- import { existsSync, readdirSync, statSync } from 'node:fs'
1
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
2
2
  import { join } from 'node:path'
3
3
 
4
- import { buildCounts, buildSummaryLine, buildView, combineBuilds, loadSettingsDifferences, refOfTarget, type ItemBuild, type ItemBuildState } from './local-applied'
4
+ import { parse as parseYaml } from 'yaml'
5
+
6
+ import { BUILD_LABEL, buildCounts, buildSummaryLine, buildView, combineBuilds, failureFix, loadBuild, loadSettingsDifferences, NEEDS_VALUE, refOfTarget, withSetupNeeded, type ItemBuild, type ItemBuildState, type SolutionItem } from './local-applied'
7
+ import { agentActionGapLine, agentActionGaps } from './local-agent-action-grants'
8
+ import { agentLeanPlugins, agentLeanPluginsLine } from './local-agent-lean-plugins'
9
+ import { agentMissingTools, isMissingToolAdaptation, missingToolsLine } from './local-agent-missing-tools'
10
+ import { blueprintFile } from './local-blueprint-errors'
11
+ import { emptyWorkflowLine, emptyWorkflows, workspaceWorkflows } from './local-empty-workflows'
5
12
  import { blueprintBuild, localOrganizationId, objectStatus, projectBlueprintOutline } from './local-blueprint-outline'
13
+ import { liveActionRuns, type LiveAction } from './local-actions'
14
+ import type { LiveApp } from './local-app-runtime'
15
+ import { appCodeStates, appPath, customAppFolder, type AppCodeState, type CustomAppFolder } from './local-apps'
6
16
  import { allTargets } from './local-dev-sync'
17
+ import { functionSecretNames, hasSecretValue, type SecretPresence, workflowSecretNames } from './local-secrets'
18
+ import { settingEffect } from './local-org-settings'
19
+ import { readLock } from './local-sync-plan'
20
+ import { readSnapshot } from './local-sync-snapshot'
7
21
 
8
22
  /**
9
23
  * How the last `local dev` left the project, counted one way for every
@@ -30,6 +44,11 @@ const projectOf = (root: string, id = '') => ({ root, id, stateDir: join(root, '
30
44
  export function projectItemBuilds(root: string, id = ''): Record<string, ItemBuild> | null {
31
45
  const org = localOrganizationId(root)
32
46
  if (!org) return null
47
+ return itemBuildsFor(root, id, org)
48
+ }
49
+
50
+ /** Each item's build by reference; with no organization (never started), every item is not applied yet. */
51
+ function itemBuildsFor(root: string, id: string, org: string | null): Record<string, ItemBuild> {
33
52
  const view = buildView(projectOf(root, id), org)
34
53
  const byRef = new Map<string, ItemBuild[]>()
35
54
  for (const target of allTargets(root)) {
@@ -38,7 +57,39 @@ export function projectItemBuilds(root: string, id = ''): Record<string, ItemBui
38
57
  const ref = refOfTarget(target)
39
58
  byRef.set(ref, [...(byRef.get(ref) ?? []), view.get(target.key)!])
40
59
  }
41
- return Object.fromEntries([...byRef].map(([ref, list]) => [ref, combineBuilds(list)]))
60
+ // An Agent whose instructions name Actions its grants never offer: said on its row, its state unchanged.
61
+ const warned = new Map<string, string[]>()
62
+ for (const gap of agentActionGaps(root)) warned.set(`agent/${gap.agent}`, [...(warned.get(`agent/${gap.agent}`) ?? []), agentActionGapLine(gap)])
63
+ // An Agent whose Plugins the lean profile leaves out (behavior, no computer): said on its row, its state unchanged.
64
+ for (const g of agentLeanPlugins(root)) warned.set(`agent/${g.agent}`, [...(warned.get(`agent/${g.agent}`) ?? []), agentLeanPluginsLine(g)])
65
+ // A workflow with no steps applies, but does nothing: said on its row, its state unchanged.
66
+ const empty = emptyWorkflows(root)
67
+ const inWorkspace = empty.length ? workspaceWorkflows(root) : null
68
+ for (const slug of empty) warned.set(`workflow/${slug}`, [...(warned.get(`workflow/${slug}`) ?? []), emptyWorkflowLine(slug, inWorkspace)])
69
+ const combined = Object.fromEntries([...byRef].map(([ref, list]) => [ref, combineBuilds(list)] as const))
70
+ // An Agent whose bound Functions or workflows failed to apply runs without them: a warning on its row, not an adaptation.
71
+ const agents = [...byRef.keys()].filter((r) => r.startsWith('agent/')).map((r) => r.slice('agent/'.length))
72
+ const missing = new Set<string>()
73
+ for (const m of agentMissingTools(root, agents, combined)) {
74
+ const ref = `agent/${m.agent}`
75
+ missing.add(ref)
76
+ warned.set(ref, [missingToolsLine(m), ...(warned.get(ref) ?? [])])
77
+ }
78
+ return Object.fromEntries(Object.entries(combined).map(([ref, built]) => {
79
+ const own = missing.has(ref) ? withoutMissingTools(built) : built
80
+ const warnings = warned.get(ref)
81
+ return [ref, warnings ? { ...own, warnings } : own]
82
+ }))
83
+ }
84
+
85
+ /** An Agent's build without the adaptations its missing tools recorded: they are its warning now. Adapted by nothing else, it is built. */
86
+ function withoutMissingTools(b: ItemBuild): ItemBuild {
87
+ const adaptations = b.adaptations.filter((a) => !isMissingToolAdaptation(a))
88
+ if (adaptations.length === b.adaptations.length) return b
89
+ if (b.state !== 'adapted') return { ...b, adaptations }
90
+ // A reason that was the adaptations, said together, follows them; one of its own stays.
91
+ const reason = b.reason === b.adaptations.join('; ') ? adaptations.join('; ') || null : b.reason
92
+ return adaptations.length ? { ...b, adaptations, reason } : { ...b, state: 'built', label: BUILD_LABEL.built, reason: null, adaptations }
42
93
  }
43
94
 
44
95
  /** One item's build, with each adaptation in words: what an Agent's page says about how it runs here. */
@@ -51,22 +102,64 @@ export interface ProjectBuildSummary {
51
102
  line: string
52
103
  /** Short: "2 problems", or "All 12 built" — what to say first. */
53
104
  headline: string
54
- /** Blocked and failed together: what does not run. */
105
+ /** Blocked, failed and needing setup together: what does not run, or fails when used. */
55
106
  problems: number
107
+ /** Every row but the Custom Apps not deployed here, by state: what `local dev` applies. */
56
108
  counts: Record<ItemBuildState, number>
109
+ /** The Custom Apps not deployed here, by their own status (`appStatus`): counted apart, as `local dev` doesn't deploy them. */
110
+ apps: Partial<Record<AppStatus, number>>
57
111
  /** Where the runtime's organization settings differ from the workspace's, one line each. */
58
112
  settings: string[]
113
+ /** Each of `settings`, with what it changes when testing here and whether it can be aligned (`settingEffect`). */
114
+ settingDetails: Array<{ line: string; effect: string | null; align: string | null }>
115
+ }
116
+
117
+ /** One item the build is counted over: what `status` lists, and what the summary counts, the same rows. */
118
+ export interface BuildRow extends ItemBuild {
119
+ /** `agent/desk`, `workflow/claim-intake`, `objectType/Ticket`. */
120
+ ref: string
121
+ /** A Custom App's code (`app/<name>`): its folder, its last deployment here and its dev server. */
122
+ app?: CustomAppFolder
123
+ /** A Custom App's one status (`app/<name>`): deployed to this runtime, built, not built, no code, or code not pulled. */
124
+ appStatus?: AppStatus
125
+ /** A saved object set (`objectSet/<ref>`): its id on the runtime, what an App's code keeps. */
126
+ id?: string
127
+ /**
128
+ * A Custom App the folder says is deployed that the runtime does not have
129
+ * (a database restore, a reset): why, in a line. Its status is then
130
+ * `built` (or `not-built`), not `deployed`.
131
+ */
132
+ notOnRuntime?: string
133
+ }
134
+
135
+ /**
136
+ * Every item the project's build is said over, once: each item the runtime
137
+ * applies (Agents, workflows, Functions, skills, knowledge, Plugins, Chat
138
+ * Apps…), an Agent, workflow or Function whose folder is there but which was
139
+ * never applied, and each Blueprint object type (`objectType/<ApiName>`), as
140
+ * `objectStatus` judges it, `stubbed` included. An object type left off by
141
+ * design (it reads the platform's media) is not a row: it is not a problem to
142
+ * fix. Null before the runtime first started.
143
+ */
144
+ export function projectBuildRows(root: string, id = '', live: ReadonlyMap<string, LiveAction> | null = null, objectSetIds: Readonly<Record<string, string>> | null = null, secrets: SecretPresence | null = null, apps: ReadonlyMap<string, LiveApp> | null = null): BuildRow[] | null {
145
+ const built = projectItemBuilds(root, id)
146
+ if (!built) return null
147
+ return buildRowsFrom(root, id, built, live, objectSetIds, secrets, apps)
59
148
  }
60
149
 
61
150
  /**
62
- * The project's build in a few numbers, over every Agent, workflow, Function
63
- * and Blueprint object type the project holds. An object type left off by
64
- * design (it reads the platform's media) is not counted: it is not a problem
65
- * to fix. Null before the runtime first started, or when there is nothing.
151
+ * The rows the first apply will be counted over, before the runtime ever
152
+ * started: the same rows `projectBuildRows` gives once it has, so "start the
153
+ * runtime to apply 290 items" and the count after it say one number.
66
154
  */
67
- export function projectBuildSummary(root: string, id = ''): ProjectBuildSummary | null {
68
- const builds = projectItemBuilds(root, id)
69
- if (!builds) return null
155
+ export function projectRowsBeforeApply(root: string): BuildRow[] {
156
+ return buildRowsFrom(root, '', itemBuildsFor(root, '', null), null, null, null, null)
157
+ }
158
+
159
+ function buildRowsFrom(root: string, id: string, built: Record<string, ItemBuild>, live: ReadonlyMap<string, LiveAction> | null, objectSetIds: Readonly<Record<string, string>> | null, secrets: SecretPresence | null, liveApps: ReadonlyMap<string, LiveApp> | null): BuildRow[] {
160
+ // With the runtime's secrets read (`secrets`), a "Needs a value" follows the secret now, not the last apply.
161
+ const needs = secrets ? secretsByRef(root) : null
162
+ const builds = secrets && needs ? Object.fromEntries(Object.entries(built).map(([ref, b]) => [ref, withSecretsNow(b, needs.get(ref) ?? [], secrets)])) : built
70
163
  const agents = entries(join(root, 'agents')).filter((slug) => existsSync(join(root, 'agents', slug, 'agent.json')))
71
164
  const workflows = entries(join(root, 'workflows')).filter((f) => /\.ya?ml$/.test(f)).map((f) => f.replace(/\.ya?ml$/, ''))
72
165
  const functions = entries(join(root, 'functions')).flatMap((f) => {
@@ -74,33 +167,407 @@ export function projectBuildSummary(root: string, id = ''): ProjectBuildSummary
74
167
  if (/\.ts$/.test(f) && !/\.(test|d)\.ts$/.test(f) && statSync(at).isFile()) return [f.replace(/\.ts$/, '')]
75
168
  return existsSync(join(at, 'package.json')) ? [f] : []
76
169
  })
170
+ const rows: BuildRow[] = []
171
+ const seen = new Set<string>()
172
+ // Evaluations are run, not applied.
173
+ for (const [ref, b] of Object.entries(builds)) {
174
+ if (ref.startsWith('evaluation/')) continue
175
+ rows.push({ ref, ...b })
176
+ seen.add(ref)
177
+ }
178
+ for (const ref of [...agents.map((a) => `agent/${a}`), ...workflows.map((w) => `workflow/${w}`), ...functions.map((f) => `function/${f}`)]) {
179
+ if (seen.has(ref)) continue
180
+ rows.push({ ref, state: 'not-applied', label: 'Not applied yet', reason: null, adaptations: [] })
181
+ seen.add(ref)
182
+ }
183
+ // The datasets, as their own applies left them.
184
+ const org = localOrganizationId(root)
185
+ const view = buildView(projectOf(root, id), org)
186
+ for (const target of allTargets(root)) {
187
+ if (target.kind !== 'dataset') continue
188
+ const b = view.get(target.key)
189
+ if (b) rows.push({ ref: `dataset/${target.apiName}`, ...b })
190
+ }
77
191
  const blueprint = blueprintBuild(root)
78
- const objects = projectBlueprintOutline(root, blueprint.actions).flatMap((t): ItemBuildState[] => {
79
- const kind = objectStatus(t, blueprint).kind
80
- return kind === 'platform-only' ? [] : [kind as ItemBuildState]
192
+ for (const t of projectBlueprintOutline(root, blueprint.actions)) {
193
+ const status = objectStatus(t, blueprint)
194
+ if (status.kind === 'platform-only') continue
195
+ const state = status.kind as ItemBuildState
196
+ rows.push({
197
+ ref: `objectType/${t.apiName}`,
198
+ state,
199
+ label: status.label,
200
+ reason: status.reason ?? null,
201
+ adaptations: state === 'adapted' || state === 'stubbed' ? [status.reason ?? status.label] : [],
202
+ })
203
+ }
204
+ // The rest of the Blueprint goes as the Blueprint went: each link type, metric, semantic
205
+ // type, shared field and saved object set is built when it applied, and fails with it.
206
+ const whole: Omit<ItemBuild, 'adaptations'> = blueprint.failed
207
+ ? { state: 'failed', label: 'Failed', reason: blueprint.failed }
208
+ : blueprint.applied
209
+ ? { state: 'built', label: 'Built', reason: null }
210
+ : { state: 'not-applied', label: 'Not applied yet', reason: null }
211
+ // A saved set's id: read from the runtime when given, else as the last Blueprint apply saved it.
212
+ const setIds = objectSetIds ?? loadBuild(projectOf(root, id), org).blueprint?.objectSetIds ?? {}
213
+ for (const [folder, kind] of BLUEPRINT_PARTS) {
214
+ for (const name of blueprintNames(root, folder)) {
215
+ const setId = kind === 'objectSet' ? setIds[name] : undefined
216
+ // Left off this runtime: refused (a failure, with the runtime's words) or naming a type left off.
217
+ const part = blueprint.parts?.[`${kind}/${name}`]
218
+ if (part) {
219
+ const reason = `Left off here: ${part.reason}`
220
+ rows.push(part.refused
221
+ ? { ref: `${kind}/${name}`, state: 'failed', label: 'Left off: the runtime refused it', reason: part.reason.replace(/^the local runtime refused it: /, ''), adaptations: [], fix: failureFix({}, blueprintFile(root, folder, name)) }
222
+ : { ref: `${kind}/${name}`, state: 'adapted', label: 'Left off here', reason, adaptations: [reason] })
223
+ continue
224
+ }
225
+ rows.push({ ref: `${kind}/${name}`, ...whole, adaptations: [], ...(setId ? { id: setId } : {}) })
226
+ }
227
+ }
228
+ // Each governed Action, as it was deployed here: running, switched off, or not deployed.
229
+ // With the runtime read (`live`, what `frontera action list` reads), its deployment decides, not the last build.
230
+ for (const name of blueprintNames(root, 'actions')) {
231
+ // Refused at the last apply, and a Blueprint file changed since: still a failure (unless the runtime runs it now),
232
+ // so the count doesn't drop while the change waits for `local dev`, in status and Studio alike.
233
+ const before = blueprint.refusedBefore?.[name]
234
+ const row = before && !liveActionRuns(live?.get(name))
235
+ ? changedSinceFailure(actionRow(before, whole))
236
+ : live ? liveActionRow(live.get(name), blueprint.actions?.[name], whole) : actionRow(blueprint.actions?.[name], whole)
237
+ // A refused or undeployed Action is fixed in its own file, as any failed item is.
238
+ // One deployed as the workspace runs it says what its owner fixes there, its state unchanged.
239
+ const inherited = row.state === 'failed' ? [] : blueprint.actions?.[name]?.inherited ?? []
240
+ rows.push({ ref: `action/${name}`, ...row, ...(row.state === 'failed' && !row.fix && blueprint.actions?.[name] ? { fix: failureFix({}, blueprintFile(root, 'actions', name)) } : {}), ...(inherited.length ? { warnings: inherited.map((w) => `action/${name}: ${w}`) } : {}) })
241
+ }
242
+ // Each Custom App, with one status: its code deployed to this runtime, built and not deployed, not built, not in the
243
+ // project, or none at all. `local dev` saves only what the App is (its file, and the Functions it may start): its
244
+ // code is deployed on its own, so that save is not the App's status. A save that failed is, until it is fixed.
245
+ for (const code of appCodeStates(root)) {
246
+ const ref = `app/${code.slug}`
247
+ const at = rows.findIndex((r) => r.ref === ref)
248
+ const saved = at >= 0 ? rows[at]! : null
249
+ const own = appStatusRow(root, code, liveApps)
250
+ const failed = saved && (saved.state === 'failed' || saved.state === 'blocked' || saved.state === 'needs-setup')
251
+ const row: BuildRow = failed ? { ...saved, appStatus: own.appStatus, ...(own.app ? { app: own.app } : {}) } : { ref, ...own, ...(saved?.warnings?.length ? { warnings: saved.warnings } : {}) }
252
+ if (at >= 0) rows[at] = row
253
+ else rows.push(row)
254
+ }
255
+ return rows
256
+ }
257
+
258
+ /** The secrets each workflow and Function names in its file, by reference (`workflow/intake`). */
259
+ export function secretsByRef(root: string): Map<string, string[]> {
260
+ const out = new Map<string, string[]>()
261
+ for (const target of allTargets(root)) {
262
+ if (target.kind !== 'workflow' && target.kind !== 'function') continue
263
+ try {
264
+ const text = readFileSync(join(root, target.file), 'utf8')
265
+ const names = target.kind === 'function' ? functionSecretNames(text) : workflowSecretNames(parseYaml(text))
266
+ const ref = refOfTarget(target)
267
+ out.set(ref, [...new Set([...(out.get(ref) ?? []), ...names])])
268
+ } catch {
269
+ // Unreadable: it names nothing here.
270
+ }
271
+ }
272
+ return out
273
+ }
274
+
275
+ /**
276
+ * An item's build with its secrets as the runtime holds them now: a "Needs a
277
+ * value for X" the last apply recorded goes once X has a value, and one is
278
+ * added for a secret the item names that has none now (a database restore can
279
+ * leave a secret listed without its value). An item with nothing else
280
+ * adapted is built again. Only built and adapted items change: what failed or
281
+ * is blocked says its own reason.
282
+ */
283
+ export function withSecretsNow(build: ItemBuild, needs: readonly string[], secrets: SecretPresence): ItemBuild {
284
+ if (build.state !== 'built' && build.state !== 'adapted' && build.state !== 'needs-setup') return build
285
+ const kept = build.adaptations.filter((a) => {
286
+ const name = NEEDS_VALUE.exec(a)?.[1]
287
+ return !name || !hasSecretValue(secrets, name)
81
288
  })
82
- const listed = new Set([...agents.map((a) => `agent/${a}`), ...workflows.map((w) => `workflow/${w}`), ...functions.map((f) => `function/${f}`)])
83
- const states: ItemBuildState[] = [
84
- ...[...listed].map((ref) => builds[ref]?.state ?? 'not-applied'),
85
- // The other kinds the runtime applies (skills, knowledge, Plugins, Chat Apps…); evaluations are run, not applied.
86
- ...Object.entries(builds).filter(([ref]) => !listed.has(ref) && !ref.startsWith('evaluation/')).map(([, b]) => b.state),
87
- ...objects,
88
- ]
89
- if (!states.length) return null
289
+ const said = new Set(kept.flatMap((a) => NEEDS_VALUE.exec(a)?.[1] ?? []))
290
+ const added = needs
291
+ .filter((n) => !said.has(n) && !hasSecretValue(secrets, n))
292
+ .map((n) => `Needs a value for ${n}${secrets.names.has(n) ? ': listed, but this runtime has no value for it' : ''}: set it with \`frontera secret set ${n}\` in the project folder`)
293
+ if (kept.length === build.adaptations.length && !added.length) return build
294
+ const adaptations = [...kept, ...added]
295
+ if (!adaptations.length) return { state: 'built', label: 'Built', reason: null, adaptations: [] }
296
+ // A secret still without a value: it needs setup; the rest is adapted.
297
+ const setup = withSetupNeeded('adapted', adaptations)
298
+ const state = setup?.state ?? 'adapted'
299
+ return { state, label: BUILD_LABEL[state], reason: setup?.reason ?? adaptations.join('; '), adaptations }
300
+ }
301
+
302
+ /**
303
+ * The project's files against the runtime, for `local status`: `local dev`
304
+ * is the next step only for files changed since their last apply. A file
305
+ * whose apply failed as it is now, or that waits for another item, is
306
+ * counted with a pointer to its Build row, which says its own reason and fix:
307
+ * applying the same files again changes nothing.
308
+ */
309
+ export function projectFilesLines(files: SolutionItem[]): string[] {
310
+ // A Blueprint that applied with refused items is on the runtime: the Build lines below name what it refused.
311
+ const changed = files.filter((f) => (f.state === 'new' || f.state === 'changed') && !f.waiting)
312
+ const failed = files.filter((f) => f.state === 'failed')
313
+ const waiting = files.filter((f) => f.waiting)
314
+ const behind = changed.length + failed.length + waiting.length
315
+ if (!behind) return [` Project all ${files.length} files applied`]
316
+ const names = (list: SolutionItem[]) => `${list.slice(0, 6).map((f) => f.name).join(', ')}${list.length > 6 ? ` and ${list.length - 6} more` : ''}`
317
+ const lines = [` Project ${behind} of ${files.length} files not on the runtime`]
318
+ if (changed.length) lines.push(` ${changed.length} changed since the last apply (${names(changed)}) — run \`frontera local dev\``)
319
+ // A failure that is the runtime's (no embedding model that runs), not the file's, applies again once that is fixed.
320
+ const notTheFile = failed.filter((f) => f.fix?.startsWith('not its file'))
321
+ if (failed.length) lines.push(` ${failed.length} didn’t apply as ${failed.length === 1 ? 'it is' : 'they are'} (${names(failed)}): each one’s reason and fix are under Build; \`frontera local dev\` changes nothing until ${failed.length === 1 ? 'its file changes' : 'their files change'}${notTheFile.length ? `, except ${names(notTheFile)}: ${notTheFile.length === 1 ? 'it applies' : 'they apply'} once the runtime is fixed as ${notTheFile.length === 1 ? 'its' : 'their'} fix says` : ''}`)
322
+ if (waiting.length) lines.push(` ${waiting.length} wait${waiting.length === 1 ? 's' : ''} for another item (${names(waiting)}): ${waiting.length === 1 ? 'it applies' : 'they apply'} once that one does (under Build)`)
323
+ return lines
324
+ }
325
+
326
+ /**
327
+ * A Custom App's one status, said alike by `frontera local status`, Forge
328
+ * and Studio: its code deployed to this runtime; built (its `out/`) and not
329
+ * deployed; not built; not in the project (pulled without it); or none at
330
+ * all (it was never deployed in the workspace). Each but the first says the
331
+ * command that moves it on.
332
+ */
333
+ export type AppStatus = 'deployed' | 'built' | 'not-built' | 'no-code' | 'not-pulled'
334
+
335
+ export const APP_STATUS_LABEL: Record<AppStatus, string> = {
336
+ deployed: 'Deployed to this runtime',
337
+ built: 'Built, not deployed',
338
+ 'not-built': 'Not built',
339
+ 'no-code': 'No code',
340
+ 'not-pulled': 'Code not pulled',
341
+ }
342
+
343
+ type AppStatusRow = ItemBuild & { appStatus: AppStatus; app?: CustomAppFolder; notOnRuntime?: string }
344
+
345
+ /** Why the runtime doesn't have the deployment the folder names, or null when it has it (or wasn't asked). */
346
+ function notOnRuntimeReason(app: CustomAppFolder, live: LiveApp | undefined): string | null {
347
+ if (app.away) return `its deployment ${app.away.deployment} went with the database restore (${app.away.restoredAt.slice(0, 16).replace('T', ' ')})${app.deployment ? `; the runtime has ${app.deployment.id}` : ''}`
348
+ if (!app.deployment || !live) return null
349
+ if (!live.app) return `the runtime has no App by this folder's id, so deployment ${app.deployment.id} isn't there (a restore of its database, or a reset?)`
350
+ if (live.deployment === false) return `the runtime's App has no deployment ${app.deployment.id} (a restore of its database?)`
351
+ return null
352
+ }
353
+
354
+ /** A dev server that could not open the browser's session: said with what fixes it. */
355
+ function devSessionWords(app: CustomAppFolder): string {
356
+ return app.devSessionError ? `; its dev server at ${app.devServer} couldn't open a session (${app.devSessionError}): reload the page, and if it still fails, restart it (\`app_dev\` stop, then start)` : ''
357
+ }
358
+
359
+ /** A Custom App with its code in the project: deployed here (and whether its code moved since), built, or not built. */
360
+ export function customAppRow(app: CustomAppFolder, live?: LiveApp): AppStatusRow {
361
+ const dev = app.devServer ? `; its dev server runs at ${app.devServer}${devSessionWords(app)}` : ''
362
+ const gone = notOnRuntimeReason(app, live)
363
+ if (gone) {
364
+ const fix = `\`frontera app deploy\` in ${app.dir} deploys it again (\`frontera local dev\` does after a restore)`
365
+ return { state: 'not-applied', appStatus: app.built ? 'built' : 'not-built', label: 'Not on the runtime', reason: `Not on the runtime: ${gone}${dev}`, adaptations: [], fix, notOnRuntime: gone }
366
+ }
367
+ if (app.deployment) {
368
+ const moved = app.changedSinceDeploy
369
+ return { state: 'built', appStatus: 'deployed', label: APP_STATUS_LABEL.deployed, reason: `Deployment ${app.deployment.id}${moved ? ', and its code changed since' : ''}${dev}`, adaptations: [], ...(moved ? { fix: `\`frontera app deploy\` in ${app.dir} deploys the change` } : {}) }
370
+ }
371
+ const pulled = app.pulled ? `Pulled at version ${app.pulled.version}; ` : ''
372
+ if (app.built) return { state: 'not-applied', appStatus: 'built', label: APP_STATUS_LABEL.built, reason: `${pulled}${pulled ? 'its' : 'Its'} build is in ${app.dir}/out${dev}`, adaptations: [], fix: `\`frontera app deploy\` in ${app.dir}` }
373
+ return { state: 'not-applied', appStatus: 'not-built', label: APP_STATUS_LABEL['not-built'], reason: `${pulled}${pulled ? 'its' : 'Its'} code is in ${app.dir}${dev || ': `frontera app dev` there runs it against this runtime'}`, adaptations: [], fix: `\`frontera app deploy\` in ${app.dir} builds and deploys it` }
374
+ }
375
+
376
+ /** Any Custom App the project names, by where its code is: in its folder, not pulled, or none. */
377
+ export function appStatusRow(root: string, code: AppCodeState, live: ReadonlyMap<string, LiveApp> | null = null): AppStatusRow {
378
+ if (code.code === 'folder') {
379
+ const app = customAppFolder(root, code.dir)
380
+ return { ...customAppRow(app, live?.get(app.name)), app }
381
+ }
382
+ if (code.code === 'not-pulled') {
383
+ return { state: 'not-applied', appStatus: 'not-pulled', label: APP_STATUS_LABEL['not-pulled'], reason: `Its code (version ${code.version}) isn’t in the project; ${appPath(code.slug)} is what the App is`, adaptations: [], fix: `\`frontera local sync pull app/${code.slug}\` brings it into apps/${code.slug}/` }
384
+ }
385
+ return { state: 'not-applied', appStatus: 'no-code', label: APP_STATUS_LABEL['no-code'], reason: `${appPath(code.slug)} describes it only: it was never deployed in the workspace, so it has no code`, adaptations: [], fix: '`frontera app init` in apps/ starts its code' }
386
+ }
387
+
388
+ /** A Custom App counted by its own status, apart from what `local dev` applies: one whose code isn't deployed here. */
389
+ export const appNotDeployed = (r: Pick<BuildRow, 'appStatus' | 'state'>): boolean => !!r.appStatus && r.appStatus !== 'deployed' && r.state === 'not-applied'
390
+
391
+ /** "2 Custom Apps not deployed here (1 built, not deployed, 1 no code)": null when every App is deployed, or there is none. */
392
+ export function appsNotDeployedText(apps: Partial<Record<AppStatus, number>>): string | null {
393
+ const parts = (['built', 'not-built', 'not-pulled', 'no-code'] as const).filter((s) => apps[s]).map((s) => `${apps[s]} ${APP_STATUS_LABEL[s].charAt(0).toLowerCase()}${APP_STATUS_LABEL[s].slice(1)}`)
394
+ const n = (['built', 'not-built', 'not-pulled', 'no-code'] as const).reduce((sum, s) => sum + (apps[s] ?? 0), 0)
395
+ return n ? `${n} Custom App${n === 1 ? '' : 's'} not deployed here (${parts.join(', ')})` : null
396
+ }
397
+
398
+ /** The Custom Apps in a line, each with its status and what moves it on; null when the project has none. */
399
+ export function appsStatusText(rows: BuildRow[]): string | null {
400
+ const apps = rows.filter((r) => r.appStatus)
401
+ if (!apps.length) return null
402
+ return apps
403
+ .map((r) => {
404
+ const name = r.ref.slice('app/'.length)
405
+ // A save of what the App is that failed here is its status until fixed, with its reason.
406
+ if (r.state !== 'not-applied' && r.state !== 'built') return `${name} ${r.label.toLowerCase()}${r.reason ? ` (${r.reason})` : ''}${r.fix ? `: ${r.fix}` : ''}`
407
+ if (r.notOnRuntime) return `${name} not on the runtime (${r.notOnRuntime})${r.app?.devSessionError ? `, its dev server can't open a session: restart it` : ''}${r.fix ? `: ${r.fix}` : ''}`
408
+ const status = APP_STATUS_LABEL[r.appStatus!].toLowerCase()
409
+ const d = r.app?.deployment
410
+ const deployed = d ? ` (${d.id}${d.at ? `, ${d.at.slice(0, 16).replace('T', ' ')}` : ''})${r.app!.changedSinceDeploy ? ', code changed since' : ''}` : ''
411
+ const pulled = r.app?.pulled && !d ? `, pulled at version ${r.app.pulled.version}` : ''
412
+ return `${name} ${status}${deployed}${pulled}${r.app?.devServer ? `, dev server ${r.app.devServer}${r.app.devSessionError ? ` (it couldn't open a session: reload the page, else restart it)` : ''}` : ''}${r.fix ? `: ${r.fix}` : ''}`
413
+ })
414
+ .join('; ')
415
+ }
416
+
417
+ /** The Blueprint's folders whose files are rows of their own, and the reference each is named by. */
418
+ const BLUEPRINT_PARTS: Array<[string, string]> = [
419
+ ['link-types', 'linkType'],
420
+ ['metrics', 'metric'],
421
+ ['semantic-types', 'semanticType'],
422
+ ['shared-fields', 'sharedField'],
423
+ ['object-sets', 'objectSet'],
424
+ ]
425
+
426
+ /** The names of a Blueprint folder's files: each file's apiName (an object set's file name). */
427
+ function blueprintNames(root: string, folder: string): string[] {
428
+ return entries(join(root, 'blueprint', folder))
429
+ .filter((f) => /\.(ya?ml|json)$/.test(f))
430
+ .map((f) => f.replace(/\.(ya?ml|json)$/, ''))
431
+ .sort()
432
+ }
433
+
434
+ /** One governed Action's row, from how the last Blueprint build deployed it. */
435
+ function actionRow(build: { status: string; reason?: string; refused?: boolean } | undefined, whole: Omit<ItemBuild, 'adaptations'>): ItemBuild {
436
+ if (!build) return { ...whole, ...(whole.state === 'built' ? { state: 'not-applied' as const, label: 'Not deployed yet' } : {}), adaptations: [] }
437
+ if (build.status === 'runs') return { state: 'built', label: 'Runs here', reason: null, adaptations: [] }
438
+ if (build.status === 'switched-off') {
439
+ const reason = `Switched off here: ${build.reason ?? 'it changes a system outside the local runtime'}`
440
+ return { state: 'stubbed', label: 'Switched off here', reason, adaptations: [reason] }
441
+ }
442
+ // The runtime refused the Action's own file: a failure to fix, with the runtime's words.
443
+ if (build.status === 'left-off' && build.refused) return { state: 'failed', label: 'Left off: the runtime refused it', reason: (build.reason ?? 'the local runtime refused it').replace(/^the local runtime refused it: /, ''), adaptations: [] }
444
+ if (build.status === 'left-off') {
445
+ const reason = `Left off here: ${build.reason ?? 'its object type is left off'}`
446
+ return { state: 'adapted', label: 'Left off here', reason, adaptations: [reason] }
447
+ }
448
+ return { state: 'failed', label: 'Didn’t deploy', reason: build.reason ?? null, adaptations: [] }
449
+ }
450
+
451
+ /** A failure from the last apply whose files changed since: still failed, said as at its last apply. */
452
+ function changedSinceFailure(row: ItemBuild): ItemBuild {
453
+ return { ...row, reason: `${row.reason ?? 'The runtime refused it'} (at its last apply; its files changed since, and \`frontera local dev\` applies it again)` }
454
+ }
455
+
456
+ /**
457
+ * One governed Action's row from its deployment on the runtime. One the last
458
+ * build switched off or left off by design keeps that row when the runtime
459
+ * agrees; anything else not running is a failure, said with the runtime's state.
460
+ */
461
+ export function liveActionRow(live: LiveAction | undefined, build: { status: string; reason?: string; refused?: boolean } | undefined, whole: Omit<ItemBuild, 'adaptations'>): ItemBuild {
462
+ if (build && (build.status === 'switched-off' || build.status === 'left-off') && (!live || live.deploymentStatus !== 'active')) return actionRow(build, whole)
463
+ if (!live) return whole.state === 'built' ? { state: 'not-applied', label: 'Not published here', reason: null, adaptations: [] } : { ...whole, adaptations: [] }
464
+ if (live.deploymentStatus === 'active' && (!live.availability || live.availability === 'available')) return { state: 'built', label: 'Runs here', reason: null, adaptations: [] }
465
+ const why = `Its deployment here is ${live.deploymentStatus}${live.availability && live.availability !== live.deploymentStatus ? ` (${live.availability})` : ''}${live.reason ? `: ${live.reason}` : ''}; \`frontera local dev\` deploys it again`
466
+ return { state: 'failed', label: 'Doesn’t run here', reason: build?.status === 'failed' && build.reason ? `${why} (last apply: ${build.reason})` : why, adaptations: [] }
467
+ }
468
+
469
+ /** The rows by kind, in a line: "3 Agents, 6 object types, 6 datasets, 6 link types…". */
470
+ export function itemKindsText(rows: BuildRow[]): string {
471
+ const WORDS: Record<string, [string, string]> = {
472
+ agent: ['Agent', 'Agents'], workflow: ['workflow', 'workflows'], function: ['Function', 'Functions'], dataset: ['dataset', 'datasets'],
473
+ objectType: ['object type', 'object types'], linkType: ['link type', 'link types'], metric: ['metric', 'metrics'], semanticType: ['semantic type', 'semantic types'],
474
+ sharedField: ['shared field', 'shared fields'], objectSet: ['saved object set', 'saved object sets'], action: ['governed Action', 'governed Actions'],
475
+ knowledge: ['knowledge base', 'knowledge bases'], skill: ['skill', 'skills'], plugin: ['Plugin', 'Plugins'], theme: ['slide theme', 'slide themes'],
476
+ 'chat-app': ['Chat App', 'Chat Apps'], app: ['Custom App', 'Custom Apps'],
477
+ }
478
+ const counts = new Map<string, number>()
479
+ for (const r of rows) {
480
+ const kind = r.ref.includes('/') ? r.ref.slice(0, r.ref.indexOf('/')) : r.ref
481
+ counts.set(kind, (counts.get(kind) ?? 0) + 1)
482
+ }
483
+ return `${rows.length} in all: ${[...counts].map(([k, n]) => `${n} ${(WORDS[k] ?? [k, k])[n === 1 ? 0 : 1]}`).join(', ')}`
484
+ }
485
+
486
+ /** The saved object sets in a line, each with its id when known: "open-work-orders 48536fc6-…"; null when the project has none. */
487
+ export function objectSetsStatusText(rows: BuildRow[]): string | null {
488
+ const sets = rows.filter((r) => r.ref.startsWith('objectSet/'))
489
+ if (!sets.length) return null
490
+ return sets.map((r) => `${r.ref.slice('objectSet/'.length)} ${r.id ?? `(${r.label.toLowerCase()}, no id yet)`}`).join('; ')
491
+ }
492
+
493
+ /**
494
+ * The governed Actions in a line: how many, and how each was deployed here;
495
+ * `none` when the project has none, which the status says outright.
496
+ */
497
+ export function actionsStatusText(rows: BuildRow[]): string {
498
+ const actions = rows.filter((r) => r.ref.startsWith('action/'))
499
+ if (!actions.length) return 'none (blueprint/actions/ is empty: nothing writes the Blueprint’s records)'
500
+ const by = (state: ItemBuildState) => actions.filter((a) => a.state === state).map((a) => a.ref.slice('action/'.length))
501
+ const parts = [
502
+ by('built').length ? `${by('built').length} run here` : '',
503
+ by('stubbed').length ? `${by('stubbed').length} switched off (${by('stubbed').join(', ')})` : '',
504
+ by('adapted').length ? `${by('adapted').length} left off (${by('adapted').join(', ')})` : '',
505
+ by('failed').length ? `${by('failed').length} didn’t deploy (${by('failed').join(', ')})` : '',
506
+ by('blocked').length ? `${by('blocked').length} blocked (${by('blocked').join(', ')})` : '',
507
+ by('not-applied').length ? `${by('not-applied').length} not deployed yet (${by('not-applied').join(', ')})` : '',
508
+ ].filter(Boolean)
509
+ return `${actions.length}: ${parts.join(', ')}`
510
+ }
511
+
512
+ /**
513
+ * The project's build in a few numbers, counted over exactly the rows
514
+ * `projectBuildRows` gives, so a count always matches the list beside it.
515
+ * Null before the runtime first started, or when there is nothing.
516
+ */
517
+ export function projectBuildSummary(root: string, id = '', live: ReadonlyMap<string, LiveAction> | null = null, secrets: SecretPresence | null = null, liveApps: ReadonlyMap<string, LiveApp> | null = null): ProjectBuildSummary | null {
518
+ const rows = projectBuildRows(root, id, live, null, secrets, liveApps)
519
+ if (!rows?.length) return null
520
+ // A Custom App whose code isn't deployed here is counted by its own status: `local dev` doesn't deploy it.
521
+ const states = rows.filter((r) => !appNotDeployed(r)).map((r) => r.state)
90
522
  const counts = buildCounts(states)
523
+ const apps: Partial<Record<AppStatus, number>> = {}
524
+ for (const r of rows) if (appNotDeployed(r)) apps[r.appStatus!] = (apps[r.appStatus!] ?? 0) + 1
91
525
  const org = localOrganizationId(root)
92
526
  const settings = org ? loadSettingsDifferences(projectOf(root, id), org) : []
93
- const problems = counts.blocked + counts.failed
94
- const line = settings.length ? `${buildSummaryLine(counts)} · ${settings.length} setting${settings.length === 1 ? ' differs' : 's differ'} from the workspace` : buildSummaryLine(counts)
527
+ const problems = counts.blocked + counts.failed + counts['needs-setup']
528
+ const appsLine = appsNotDeployedText(apps)
529
+ const line = [buildSummaryLine(counts), appsLine ?? '', settings.length ? `${settings.length} setting${settings.length === 1 ? ' differs' : 's differ'} from the workspace` : ''].filter(Boolean).join(' · ')
95
530
  const headline = problems ? `${problems} problem${problems === 1 ? '' : 's'}` : counts['not-applied'] ? `${counts['not-applied']} not applied yet` : `All ${states.length} built`
96
- return { line, headline, problems, counts, settings }
531
+ const settingDetails = settings.map((l) => {
532
+ const e = settingEffect(l)
533
+ return { line: l, effect: e?.effect ?? null, align: e?.align ?? null }
534
+ })
535
+ return { line, headline, problems, counts, apps, settings, settingDetails }
97
536
  }
98
537
 
99
- /** The CLI's line for the build: the problems first, then the rest, short ("2 problems: 1 failed, 1 blocked · 7 built, 3 adapted"). */
538
+ /** The CLI's line for the build: the problems first, then the rest, short ("3 problems: 1 failed, 1 blocked, 1 needs setup · 7 built, 3 adapted"). */
100
539
  export function buildStatusText(summary: ProjectBuildSummary): string {
101
540
  const c = summary.counts
102
- const problems = [c.failed ? `${c.failed} failed` : '', c.blocked ? `${c.blocked} blocked` : ''].filter(Boolean).join(', ')
541
+ const problems = [c.failed ? `${c.failed} failed` : '', c.blocked ? `${c.blocked} blocked` : '', c['needs-setup'] ? `${c['needs-setup']} need${c['needs-setup'] === 1 ? 's' : ''} setup` : ''].filter(Boolean).join(', ')
103
542
  const rest = [c.built ? `${c.built} built` : '', c.adapted ? `${c.adapted} adapted` : '', c.stubbed ? `${c.stubbed} stubbed` : '', c['not-applied'] ? `${c['not-applied']} not applied yet` : ''].filter(Boolean).join(', ')
104
543
  const settings = summary.settings.length ? `${summary.settings.length} setting${summary.settings.length === 1 ? ' differs' : 's differ'} from the workspace` : ''
105
- return [summary.problems ? `${summary.headline}: ${problems}` : rest, summary.problems ? rest : '', settings].filter(Boolean).join(' · ')
544
+ const apps = appsNotDeployedText(summary.apps ?? {}) ?? ''
545
+ return [summary.problems ? `${summary.headline}: ${problems}` : rest, summary.problems ? rest : '', apps, settings].filter(Boolean).join(' · ')
546
+ }
547
+
548
+ /**
549
+ * What the linked workspace lists that the project never pulled, by kind,
550
+ * with the command that brings it: an App the pull index names but no
551
+ * `apps/<slug>.json` holds is said, not silent. Datasets (the workspace's
552
+ * records) never come. Empty for a project with no fetched workspace.
553
+ */
554
+ export function notPulledLines(root: string): string[] {
555
+ const snap = readSnapshot(root)
556
+ if (!snap) return []
557
+ const held = new Set(Object.keys(readLock(root)?.items ?? {}))
558
+ const missing = Object.keys(snap.items).filter((ref) => !held.has(ref) && !ref.startsWith('dataset/') && !ref.startsWith('lessons/') && !ref.startsWith('blueprint/'))
559
+ if (!missing.length) return []
560
+ const byKind = new Map<string, string[]>()
561
+ for (const ref of missing.sort()) {
562
+ const kind = ref.slice(0, ref.indexOf('/'))
563
+ byKind.set(kind, [...(byKind.get(kind) ?? []), ref])
564
+ }
565
+ // `--kinds` takes the plural, but knowledge is its own.
566
+ const flagWord = (kind: string) => (kind === 'knowledge' ? kind : `${kind}s`)
567
+ return [...byKind].map(([kind, refs]) => `${refs.length} ${refs.length === 1 ? kind : flagWord(kind)} (${refs.slice(0, 4).join(', ')}${refs.length > 4 ? ', …' : ''}): \`frontera local sync pull ${refs.length > 4 ? `--kinds ${flagWord(kind)}` : refs.join(' ')}\``)
568
+ }
569
+
570
+ /** The knowledge bases with no documents here, each with the command that brings them (`emptyKnowledgeNote`). */
571
+ export function emptyKnowledgeLines(rows: BuildRow[]): string[] {
572
+ return rows.filter((r) => r.ref.startsWith('knowledge/') && /has no documents/.test(r.reason ?? '')).map((r) => r.reason!)
106
573
  }