@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
@@ -7,6 +7,7 @@ import { allTargets, describeTarget, datasetDocument, isIgnoredName, type SyncTa
7
7
  import type { LocalProject } from './local-project'
8
8
  import { standInTools } from './local-stand-ins'
9
9
  import { importedFiles } from './local-function-shared'
10
+ import { NOT_A_DIFFERENCE_HERE } from './local-org-settings'
10
11
 
11
12
  /**
12
13
  * What `frontera local dev` has applied to this runtime, by content hash. It
@@ -127,8 +128,64 @@ export interface SolutionItem {
127
128
  name: string
128
129
  /** Path in the solution (the Blueprint is its folder). */
129
130
  path: string
130
- /** `applied`: the runtime has this content; `changed`: edited since; `new`: never applied here. */
131
- state: 'applied' | 'changed' | 'new'
131
+ /**
132
+ * `applied`: the runtime has this content; `changed`: edited since; `new`: never applied here;
133
+ * `failed`: the runtime refused this content; `partial`: it applied, but the runtime refused
134
+ * some of what it holds (a governed Action, a metric): `detail` names them.
135
+ */
136
+ state: 'applied' | 'changed' | 'new' | 'failed' | 'partial'
137
+ /** failed, partial: what the runtime refused, in words; a held item (`waiting`): what it waits for. */
138
+ detail?: string
139
+ /** failed: the error's code, when the apply gave one. */
140
+ code?: string
141
+ /** failed: what fixes it. Running `local dev` again on the same files does not. */
142
+ fix?: string
143
+ /** new or changed, but the last apply of exactly these files was held back for another item (`detail`). */
144
+ waiting?: boolean
145
+ }
146
+
147
+ /** What fixes a Function whose imports the pull couldn't bring: the Function's owner, or the files here. */
148
+ export const IMPORT_MISSING_FIX = 'the pull couldn’t bring the files it imports (its source in the workspace doesn’t hold them): whoever owns the Function deploys it again from its source with a current CLI, then you pull it; or add the files here'
149
+
150
+ /**
151
+ * What fixes a knowledge base the runtime could not read because its
152
+ * organization has no embedding model that runs: not its file. The model is
153
+ * the runtime's: a start sets one from this computer's keys (the keyless
154
+ * local embeddings when there is none, or the provider refuses it).
155
+ */
156
+ export const EMBEDDING_MISSING_FIX = 'not its file: the runtime has no embedding model that runs. `frontera local status` says why under Setting and Embeddings; a key the provider refuses is replaced with `frontera local key-set <provider>`, and `frontera local stop` then `frontera local start` sets a model (the keyless local embeddings when no key works); the next `frontera local dev` reads it again'
157
+
158
+ /**
159
+ * What fixes an item whose apply failed for these very files: the hint the
160
+ * apply gave, what a known failure needs, else fixing the file. Applying the
161
+ * same files again does not.
162
+ */
163
+ export function failureFix(entry: Pick<BuildEntry, 'error' | 'code' | 'hint'>, file: string): string {
164
+ if (entry.code === 'FUNCTION_IMPORT_MISSING' || /^imports .+, which (?:isn[’']t|aren[’']t) in the project/i.test(entry.error ?? '')) return IMPORT_MISSING_FIX
165
+ if (/set this organization['’]s embedding model|embedding model .*is not available/i.test(entry.error ?? '')) return EMBEDDING_MISSING_FIX
166
+ if (entry.hint) return entry.hint
167
+ return `change ${file} so the runtime takes it; \`frontera local dev\` applies it once the file changes`
168
+ }
169
+
170
+ /** The code an apply's error carries (`error [FUNCTION_IMPORT_MISSING]: …`), and its first `hint:` line. */
171
+ export function failureCodeAndHint(output: string): { code?: string; hint?: string } {
172
+ const code = /error \[([A-Z][A-Z0-9_]*)\]/.exec(output)?.[1]
173
+ const hint = /^\s*hint:\s*(.+)$/m.exec(output)?.[1]?.trim()
174
+ return { ...(code ? { code } : {}), ...(hint ? { hint } : {}) }
175
+ }
176
+
177
+ /**
178
+ * What the runtime refused inside a Blueprint that applied, by reference
179
+ * (`action/holdAmenityBooking`, `metric/occupancyRate`, `objectType/Photo`):
180
+ * each one a failure `status`, `local check` and `local dev` count alike.
181
+ */
182
+ export function refusedBlueprintItems(entry: Pick<BuildEntry, 'actions' | 'parts' | 'objects'> | undefined): string[] {
183
+ if (!entry) return []
184
+ return [
185
+ ...Object.entries(entry.objects ?? {}).flatMap(([name, o]) => (o.refused ? [`objectType/${name}`] : [])),
186
+ ...Object.entries(entry.parts ?? {}).flatMap(([ref, p]) => (p.refused ? [ref] : [])),
187
+ ...Object.entries(entry.actions ?? {}).flatMap(([name, a]) => ((a.status === 'left-off' && a.refused) || a.status === 'failed' ? [`action/${name}`] : [])),
188
+ ]
132
189
  }
133
190
 
134
191
  /**
@@ -136,25 +193,35 @@ export interface SolutionItem {
136
193
  * dev` recorded after its last successful publish, against the files now. Null
137
194
  * when the project has no Blueprint.
138
195
  */
139
- export function blueprintApplyState(project: LocalProject, organizationId: string | null): SolutionItem['state'] | null {
196
+ export function blueprintApplyState(project: LocalProject, organizationId: string | null): 'applied' | 'changed' | 'new' | null {
140
197
  const now = treeHash(join(project.root, 'blueprint'))
141
198
  if (!now) return null
142
199
  const was = organizationId ? loadHashes(project, organizationId).get('blueprint') : undefined
143
200
  return was === undefined ? 'new' : was === now ? 'applied' : 'changed'
144
201
  }
145
202
 
146
- /** Each file `local dev` applies, and whether the runtime has its current content. */
147
- export function solutionStatus(project: LocalProject, organizationId: string | null): SolutionItem[] {
203
+ /** Each file `local dev` applies, and whether the runtime has its current content; `only` keeps the targets it accepts. */
204
+ export function solutionStatus(project: LocalProject, organizationId: string | null, only?: (target: SyncTarget) => boolean): SolutionItem[] {
148
205
  const applied = organizationId ? loadHashes(project, organizationId) : new Map<string, string | null>()
149
- return allTargets(project.root).map((target) => {
206
+ const build = loadBuild(project, organizationId)
207
+ return allTargets(project.root).filter((target) => !only || only(target)).map((target) => {
150
208
  const was = applied.get(target.key)
151
209
  const now = contentHash(project, target)
152
- return {
210
+ const item: SolutionItem = {
153
211
  kind: target.kind,
154
212
  name: describeTarget(target),
155
213
  path: target.kind === 'blueprint' ? 'blueprint/' : target.file,
156
214
  state: was === undefined ? 'new' : was === now ? 'applied' : 'changed',
157
215
  }
216
+ // The last build is for these files: what the runtime refused is said, not "Applied".
217
+ const entry = build[target.key]
218
+ if (!entry || entry.hash !== now) return item
219
+ if (entry.status === 'failed') return { ...item, state: 'failed', detail: entry.error ?? 'the runtime refused it', ...(entry.code ? { code: entry.code } : {}), fix: failureFix(entry, item.path) }
220
+ // Held back for another item: it applies once that one does, not by running `local dev` again.
221
+ if (entry.status === 'blocked' && item.state !== 'applied') return { ...item, waiting: true, detail: entry.error ?? (entry.needs?.length ? `waits for ${entry.needs.join(', ')}` : 'held back') }
222
+ const refused = target.kind === 'blueprint' ? refusedBlueprintItems(entry) : []
223
+ if (refused.length && item.state === 'applied') return { ...item, state: 'partial', detail: `the runtime refused ${refused.length === 1 ? 'one item' : `${refused.length} items`} in it: ${refused.join(', ')}` }
224
+ return item
158
225
  })
159
226
  }
160
227
 
@@ -188,13 +255,44 @@ export interface BuildEntry {
188
255
  unnamedActions?: number
189
256
  /** failed: the runtime's error, one line. */
190
257
  error?: string
258
+ /** failed: the error's code (`FUNCTION_IMPORT_MISSING`), when it had one. */
259
+ code?: string
260
+ /** failed: what the apply said fixes it (its `hint:` line), when it said. */
261
+ hint?: string
191
262
  /** The content hash the outcome is for. */
192
263
  hash: string | null
264
+ /**
265
+ * A knowledge base: how the runtime's embeddings stood against the
266
+ * workspace's when it was applied (`embeddingsDiffer`; `same` when they
267
+ * match), which decides the threshold it searches with. Another now
268
+ * applies it again, unchanged files or not.
269
+ */
270
+ embeddings?: 'same' | 'workspace' | 'platform-default' | 'keyless'
271
+ /**
272
+ * A workflow: the version its file was pushed as (the push that made this
273
+ * entry). Tests run exactly this version, live or draft: the newest draft
274
+ * can be an older push once a later one was promoted.
275
+ */
276
+ version?: number
277
+ /**
278
+ * A workflow: the version the last promotion `local dev` made made live here
279
+ * (this push's, or an earlier one's while no later push was promoted). What an
280
+ * Agent's "Promoted workflow … so … can start it" says, read now.
281
+ */
282
+ live?: number
193
283
  at: string
194
284
  /** The Blueprint: each object type as the runtime holds it. */
195
- objects?: Record<string, { status: 'built' | 'adapted' | 'stubbed' | 'left-off'; adaptations?: string[] }>
285
+ objects?: Record<string, { status: 'built' | 'adapted' | 'stubbed' | 'left-off'; adaptations?: string[]; refused?: string }>
196
286
  /** The Blueprint: each governed Action, deployed (`runs`) or not, with why. */
197
- actions?: Record<string, { status: 'runs' | 'switched-off' | 'failed' | 'left-off'; reason?: string }>
287
+ actions?: Record<string, { status: 'runs' | 'switched-off' | 'failed' | 'left-off'; reason?: string; findings?: string[]; refused?: boolean; inherited?: string[] }>
288
+ /**
289
+ * The Blueprint: each link type, metric, semantic type and shared field left off the
290
+ * runtime, by its row's reference (`metric/workOrdersRaised`), with why; `refused` when
291
+ * the runtime refused that file itself.
292
+ */
293
+ parts?: Record<string, { reason: string; refused: boolean }>
294
+ /** The Blueprint: each saved object set's id on the runtime, by its file's reference (`open-work-orders`). */
295
+ objectSetIds?: Record<string, string>
198
296
  }
199
297
 
200
298
  interface BuildFile {
@@ -217,7 +315,9 @@ function readBuildFile(project: LocalProject, organizationId: string): Partial<B
217
315
 
218
316
  /** The settings the runtime holds differently from the workspace, as `local dev` last compared them. */
219
317
  export function loadSettingsDifferences(project: LocalProject, organizationId: string | null): string[] {
220
- return (organizationId ? readBuildFile(project, organizationId)?.settings?.differences : undefined) ?? []
318
+ const recorded = (organizationId ? readBuildFile(project, organizationId)?.settings?.differences : undefined) ?? []
319
+ // A flag recorded by an older CLI that changes nothing here (`NOT_A_DIFFERENCE_HERE`) is not said.
320
+ return recorded.filter((d) => ![...NOT_A_DIFFERENCE_HERE].some((key) => d.startsWith(`Feature ${key} `)))
221
321
  }
222
322
 
223
323
  /** Record where the runtime's settings differ from the workspace's. Never throws. */
@@ -261,6 +361,12 @@ export function recordBuild(project: LocalProject, organizationId: string, key:
261
361
  }
262
362
  }
263
363
 
364
+ /** The version `local dev` last pushed a workflow's file as, when the record has one. */
365
+ export function pushedWorkflowVersion(project: LocalProject, organizationId: string | null, slug: string): number | undefined {
366
+ const entry = loadBuild(project, organizationId)[`workflow:${slug}`]
367
+ return entry && (entry.status === 'built' || entry.status === 'adapted' || entry.status === 'stubbed') && typeof entry.version === 'number' ? entry.version : undefined
368
+ }
369
+
264
370
  /** Forget the outcomes of targets the project no longer has. */
265
371
  export function pruneBuild(project: LocalProject, organizationId: string, keep: ReadonlySet<string>): void {
266
372
  const items = loadBuild(project, organizationId)
@@ -335,7 +441,26 @@ export function adaptationOf(warning: string): string {
335
441
  return text.charAt(0).toUpperCase() + text.slice(1)
336
442
  }
337
443
 
338
- export type ItemBuildState = BuildStatus | 'not-applied'
444
+ /**
445
+ * How an item stands here: its recorded build, `not-applied` when no apply
446
+ * is for its files as they are, and `needs-setup` for one that applied but
447
+ * names a secret the runtime has no value for (`Needs a value for X`): it
448
+ * is on the runtime, and fails when used until the value is set, so it is
449
+ * counted with what to fix, not with what runs adapted.
450
+ */
451
+ export type ItemBuildState = BuildStatus | 'not-applied' | 'needs-setup'
452
+
453
+ /** An adaptation saying the item names a secret with no value here. */
454
+ export const NEEDS_VALUE = /^Needs a value for ([A-Z][A-Z0-9_]*)\b/
455
+
456
+ /** Adapted or stubbed with a secret missing: `needs-setup`, its secrets said first. */
457
+ export function withSetupNeeded(state: ItemBuildState, adaptations: readonly string[]): { state: ItemBuildState; reason: string | null } | null {
458
+ if (state !== 'adapted' && state !== 'stubbed' && state !== 'needs-setup') return null
459
+ const needs = adaptations.filter((a) => NEEDS_VALUE.test(a))
460
+ if (!needs.length) return state === 'needs-setup' ? { state: 'adapted', reason: adaptations.join('; ') || null } : null
461
+ const rest = adaptations.filter((a) => !NEEDS_VALUE.test(a))
462
+ return { state: 'needs-setup', reason: [...needs, ...rest].join('; ') }
463
+ }
339
464
 
340
465
  export interface ItemBuild {
341
466
  state: ItemBuildState
@@ -344,10 +469,18 @@ export interface ItemBuild {
344
469
  /** The one line that says why, when there is one: "Needs workflow telco-fault-triage". */
345
470
  reason: string | null
346
471
  adaptations: string[]
472
+ /** failed: what fixes it (applying the same files again does not). */
473
+ fix?: string
474
+ /**
475
+ * Said beside its state without changing it: what the item's own files get
476
+ * wrong the same way on the platform (an Agent's instructions naming
477
+ * governed Actions its grants never offer it), each with its fix.
478
+ */
479
+ warnings?: string[]
347
480
  }
348
481
 
349
- const BUILD_RANK: Record<ItemBuildState, number> = { built: 0, adapted: 1, stubbed: 2, 'not-applied': 3, blocked: 4, failed: 5 }
350
- const BUILD_LABEL: Record<ItemBuildState, string> = { built: 'Built', adapted: 'Adapted', stubbed: 'Stubbed', 'not-applied': 'Not applied yet', blocked: 'Blocked', failed: 'Failed' }
482
+ const BUILD_RANK: Record<ItemBuildState, number> = { built: 0, adapted: 1, stubbed: 2, 'needs-setup': 3, 'not-applied': 4, blocked: 5, failed: 6 }
483
+ export const BUILD_LABEL: Record<ItemBuildState, string> = { built: 'Built', adapted: 'Adapted', stubbed: 'Stubbed', 'needs-setup': 'Needs setup', 'not-applied': 'Not applied yet', blocked: 'Blocked', failed: 'Failed' }
351
484
 
352
485
  function lower(text: string): string {
353
486
  return text ? text.charAt(0).toLowerCase() + text.slice(1) : text
@@ -362,7 +495,7 @@ function lower(text: string): string {
362
495
  * Blueprint".
363
496
  */
364
497
  export function buildView(project: LocalProject, organizationId: string | null): Map<string, ItemBuild> {
365
- const build = loadBuild(project, organizationId)
498
+ const build = withCurrentPromotions(loadBuild(project, organizationId))
366
499
  const applied = organizationId ? loadHashes(project, organizationId) : new Map<string, string | null>()
367
500
  const targets = allTargets(project.root)
368
501
  const byRef = new Map<string, SyncTarget[]>()
@@ -370,12 +503,16 @@ export function buildView(project: LocalProject, organizationId: string | null):
370
503
  const hashes = new Map(targets.map((t) => [t.key, contentHash(project, t)]))
371
504
  const blueprint = build.blueprint
372
505
 
373
- const stateOf = (t: SyncTarget): { state: ItemBuildState; entry?: BuildEntry } => {
506
+ const stateOf = (t: SyncTarget): { state: ItemBuildState; entry?: BuildEntry; changedSince?: boolean } => {
374
507
  const entry = build[t.key]
375
508
  const now = hashes.get(t.key) ?? null
376
509
  if (entry && entry.hash === now) return { state: entry.status, entry }
377
510
  // Applied before the build was recorded: it is built.
378
511
  if (!entry && now && applied.get(t.key) === now) return { state: 'built' }
512
+ // Failed at its last apply, and what it builds from changed since (its file, or for a Function the
513
+ // vendored SDK or a file beside it): still a failure until an apply says otherwise, not "not applied
514
+ // yet", so a refreshed SDK or a new Function next to it doesn't make the failure drop out of the count.
515
+ if (entry?.status === 'failed' && now) return { state: 'failed', entry, changedSince: true }
379
516
  return { state: 'not-applied' }
380
517
  }
381
518
 
@@ -392,9 +529,10 @@ export function buildView(project: LocalProject, organizationId: string | null):
392
529
  if (ref.startsWith('objectType/')) {
393
530
  const obj = blueprint?.objects?.[ref.slice('objectType/'.length)]
394
531
  // The Blueprint not built (failed, waiting, or never applied) holds every object type back.
395
- if (blueprint && blueprint.status !== 'built' && blueprint.status !== 'adapted') return `${words}: the Blueprint ${blueprint.status === 'failed' ? 'didn’t build' : 'isn’t built yet'}`
532
+ if (blueprint && blueprint.status !== 'built' && blueprint.status !== 'adapted' && blueprint.status !== 'stubbed') return `${words}: the Blueprint ${blueprint.status === 'failed' ? 'didn’t build' : 'isn’t built yet'}`
396
533
  if (!blueprint && existsSync(join(project.root, 'blueprint'))) return `${words}: the Blueprint isn’t built yet`
397
534
  if (!obj) return `${words}, which isn’t in the project’s Blueprint`
535
+ if (obj.refused) return `${words}, which the local runtime refused: ${obj.refused}`
398
536
  return obj.status === 'left-off' ? `${words}, which the local runtime leaves off` : words
399
537
  }
400
538
  const held = byRef.get(ref)
@@ -411,6 +549,8 @@ export function buildView(project: LocalProject, organizationId: string | null):
411
549
  if (entry.status === 'blocked') {
412
550
  const needs = entry.needs ?? []
413
551
  if (!needs.length) {
552
+ // Held back for its own reason (the apply watchdog): its words, not a missing dependency.
553
+ if (entry.error) return entry.error
414
554
  const n = entry.unnamedActions ?? 0
415
555
  return `Runs ${n === 1 ? 'a governed Action' : `${n} governed Actions`} named only by the platform’s id: add an organization key and pull it again`
416
556
  }
@@ -425,30 +565,79 @@ export function buildView(project: LocalProject, organizationId: string | null):
425
565
  const found = stateOf(t)
426
566
  const { entry } = found
427
567
  // Adapted only by what the runtime is missing (no runner has polled yet): it is built as its files say, with a note.
428
- const state = found.state === 'adapted' && entry?.adaptations?.length && entry.adaptations.every(isEnvironmentNote) ? 'built' : found.state
429
- const reason = entry ? reasonOf(entry, 0) || null : null
430
- out.set(t.key, { state, label: BUILD_LABEL[state], reason, adaptations: state === 'adapted' || state === 'stubbed' ? (entry?.adaptations ?? []) : [] })
568
+ const environmentOnly = found.state === 'adapted' && !!entry?.adaptations?.length && entry.adaptations.every(isEnvironmentNote)
569
+ // A secret with no value here: it applied, and fails when used until the value is set.
570
+ const setup = environmentOnly ? null : withSetupNeeded(found.state, entry?.adaptations ?? [])
571
+ const state: ItemBuildState = environmentOnly ? 'built' : (setup?.state ?? found.state)
572
+ // "No runner has polled for 229s" was the poll's age when it applied, not the runner now: not repeated as
573
+ // the item's reason (a restored database brings back an old poll time). "Never polled" still is.
574
+ const own = environmentOnly ? entry!.adaptations!.filter((a) => !isPollAgeNote(a)).join('; ') || null : setup ? setup.reason : entry ? reasonOf(entry, 0) || null : null
575
+ const reason = found.changedSince ? `${own ?? 'The runtime refused it'} (at its last apply; its files changed since, and \`frontera local dev\` applies it again)` : own
576
+ out.set(t.key, { state, label: BUILD_LABEL[state], reason, adaptations: state === 'adapted' || state === 'stubbed' || state === 'needs-setup' ? (entry?.adaptations ?? []) : [], ...(state === 'failed' && entry ? { fix: failureFix(entry, t.kind === 'blueprint' ? 'blueprint/' : t.file) } : {}) })
431
577
  }
432
578
  return out
433
579
  }
434
580
 
581
+ /** "Promoted workflow intake v2 so desk can start it": the adaptation an Agent's tools record when its binding promoted a workflow. */
582
+ const PROMOTED = /^Promoted workflow (\S+) v(\d+) so (.+) can start it$/
583
+
584
+ /** `promoted workflow intake v3…` in an apply's output → 3, for that workflow. */
585
+ export function promotedVersionOf(output: string, slug: string): number | undefined {
586
+ const escaped = slug.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
587
+ const all = [...output.matchAll(new RegExp(`promoted workflow ${escaped} v(\\d+)\\b`, 'gi'))]
588
+ return all.length ? Number(all[all.length - 1]![1]) : undefined
589
+ }
590
+
591
+ /**
592
+ * Each "Promoted workflow X vN so Y can start it" with the version live now:
593
+ * the latest promotion `local dev` recorded for X (its push's, `live`), not the
594
+ * one the Agent's binding made when it was applied. A record from before `live`
595
+ * was kept reads the workflow's pushed version, when it was pushed after the
596
+ * binding: once a binding promoted it, every later push is promoted too.
597
+ */
598
+ export function withCurrentPromotions(build: Record<string, BuildEntry>): Record<string, BuildEntry> {
599
+ const now = (text: string, at: string): string => {
600
+ const m = PROMOTED.exec(text)
601
+ if (!m) return text
602
+ const workflow = build[`workflow:${m[1]}`]
603
+ const recorded = workflow?.live ?? (workflow?.version !== undefined && workflow.at > at ? workflow.version : undefined)
604
+ const latest = Math.max(Number(m[2]), recorded ?? 0)
605
+ return latest === Number(m[2]) ? text : `Promoted workflow ${m[1]} v${latest} so ${m[3]} can start it`
606
+ }
607
+ let changed = false
608
+ const out: Record<string, BuildEntry> = {}
609
+ for (const [key, entry] of Object.entries(build)) {
610
+ const adaptations = entry.adaptations?.map((a) => now(a, entry.at))
611
+ if (adaptations && adaptations.some((a, i) => a !== entry.adaptations![i])) {
612
+ changed = true
613
+ out[key] = { ...entry, adaptations }
614
+ } else out[key] = entry
615
+ }
616
+ return changed ? out : build
617
+ }
618
+
435
619
  /** A warning about the runtime rather than the item: it says nothing about how the item runs differently here. */
436
620
  export function isEnvironmentNote(text: string): boolean {
437
621
  return /no automation runner has (ever )?polled/i.test(text)
438
622
  }
439
623
 
624
+ /** The deploy's "no automation runner has polled for 229s": how old the last poll was at that moment. */
625
+ export function isPollAgeNote(text: string): boolean {
626
+ return /no automation runner has polled for \d+\s?s\b/i.test(text)
627
+ }
628
+
440
629
  /** Several targets as one item (an Agent: its file, its behavior settings and its tools): the worst one speaks. */
441
630
  export function combineBuilds(builds: Array<ItemBuild | undefined>): ItemBuild {
442
631
  const present = builds.filter((b): b is ItemBuild => !!b)
443
632
  if (!present.length) return { state: 'not-applied', label: BUILD_LABEL['not-applied'], reason: null, adaptations: [] }
444
633
  const worst = [...present].sort((a, b) => BUILD_RANK[b.state] - BUILD_RANK[a.state])[0]!
445
634
  const adaptations = [...new Set(present.flatMap((b) => b.adaptations))]
446
- return { ...worst, adaptations, reason: worst.reason ?? (worst.state === 'adapted' || worst.state === 'stubbed' ? adaptations.join('; ') || null : null) }
635
+ return { ...worst, adaptations, reason: worst.reason ?? (worst.state === 'adapted' || worst.state === 'stubbed' || worst.state === 'needs-setup' ? adaptations.join('; ') || null : null) }
447
636
  }
448
637
 
449
638
  /** How many items the home lists are in each state, for "49 built · 2 adapted · 3 blocked". */
450
639
  export function buildCounts(states: ItemBuildState[]): Record<ItemBuildState, number> {
451
- const counts: Record<ItemBuildState, number> = { built: 0, adapted: 0, stubbed: 0, 'not-applied': 0, blocked: 0, failed: 0 }
640
+ const counts: Record<ItemBuildState, number> = { built: 0, adapted: 0, stubbed: 0, 'needs-setup': 0, 'not-applied': 0, blocked: 0, failed: 0 }
452
641
  for (const s of states) counts[s] += 1
453
642
  return counts
454
643
  }
@@ -459,6 +648,7 @@ export function buildSummaryLine(counts: Record<ItemBuildState, number>): string
459
648
  counts.built ? `${counts.built} built` : '',
460
649
  counts.adapted ? `${counts.adapted} adapted` : '',
461
650
  counts.stubbed ? `${counts.stubbed} stubbed` : '',
651
+ counts['needs-setup'] ? `${counts['needs-setup']} need${counts['needs-setup'] === 1 ? 's' : ''} setup` : '',
462
652
  counts.blocked ? `${counts.blocked} blocked` : '',
463
653
  counts.failed ? `${counts.failed} failed` : '',
464
654
  counts['not-applied'] ? `${counts['not-applied']} not applied yet` : '',
@@ -0,0 +1,84 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { join, sep } from 'node:path'
3
+
4
+ import { writeFileAtomic } from '../fs-atomic'
5
+ import type { LocalProject } from './local-project'
6
+
7
+ /**
8
+ * Each apply `local dev` ran, newest last: when it started and finished,
9
+ * what started it and what came of it. Forge's Activity lists each as its
10
+ * own event, so an apply nine minutes after the first says when it ran and
11
+ * why ("files changed: agents/desk/agent.json") instead of reusing the
12
+ * first one's time. A round that found nothing changed is not an apply and
13
+ * is not kept.
14
+ */
15
+
16
+ export interface ApplyRecord {
17
+ /** When its first item started applying. */
18
+ since: string
19
+ finished: string
20
+ /** What started it: "runtime started", "files changed: …", "the runtime is answering again". */
21
+ trigger: string
22
+ /** Items it applied, and items that failed, in this round only. */
23
+ applied: number
24
+ failed: number
25
+ }
26
+
27
+ /** How many applies are kept: Activity shows the recent ones. */
28
+ const KEEP = 30
29
+
30
+ function historyPath(stateDir: string): string {
31
+ return join(stateDir, 'applies.json')
32
+ }
33
+
34
+ /** The applies kept, oldest first; none when the file is missing or unreadable. */
35
+ export function readApplies(stateDir: string): ApplyRecord[] {
36
+ try {
37
+ const list = JSON.parse(readFileSync(historyPath(stateDir), 'utf8')) as unknown
38
+ return Array.isArray(list) ? list.filter((r): r is ApplyRecord => !!r && typeof (r as ApplyRecord).since === 'string' && typeof (r as ApplyRecord).trigger === 'string') : []
39
+ } catch {
40
+ return []
41
+ }
42
+ }
43
+
44
+ /** Keep one finished apply. Never throws: a missed record must never fail an apply. */
45
+ export function recordApply(project: LocalProject, record: ApplyRecord): void {
46
+ try {
47
+ const list = [...readApplies(project.stateDir), record].slice(-KEEP)
48
+ writeFileAtomic(historyPath(project.stateDir), `${JSON.stringify(list, null, 2)}\n`, 0o644)
49
+ } catch {
50
+ // The apply itself is what matters.
51
+ }
52
+ }
53
+
54
+ /** "files changed: agents/desk/agent.json and workflows/intake.yaml", or "… and 4 more". */
55
+ export function filesChangedTrigger(paths: Iterable<string>): string {
56
+ const list = [...new Set([...paths].map((p) => p.split(sep).join('/')))].sort()
57
+ if (!list.length) return 'files changed'
58
+ const shown = list.slice(0, 2)
59
+ const rest = list.length - shown.length
60
+ return `files changed: ${rest > 0 ? `${shown.join(', ')} and ${rest} more` : shown.join(' and ')}`
61
+ }
62
+
63
+ /**
64
+ * Folders whose files never change what the runtime runs: the CLI's and
65
+ * Forge's own state (`.frontera/`, screenshots under `.frontera/evidence/`
66
+ * included), the coding agents' settings (`.claude/`, `.codex/`, `.cursor/`,
67
+ * `.agents/`), Git, packages, and the project's notes (`docs/`). A change
68
+ * under one is never a reason to apply, so the watcher doesn't even wake up.
69
+ */
70
+ const NEVER_APPLIED = new Set(['.frontera', '.claude', '.codex', '.cursor', '.agents', '.git', 'node_modules', 'docs', 'dist', 'fixtures'])
71
+
72
+ /**
73
+ * Whether a changed path (relative to the project root) can change what the
74
+ * runtime runs: not under a folder above, not a hidden file or folder, and
75
+ * not a root file the runtime never reads (README.md, AGENTS.md, .mcp.json).
76
+ * `library.json` is the one root file that applies.
77
+ */
78
+ export function canChangeRuntime(rel: string): boolean {
79
+ const parts = rel.split(sep).flatMap((p) => p.split('/')).filter(Boolean)
80
+ if (!parts.length) return false
81
+ if (parts.some((p) => NEVER_APPLIED.has(p) || p.startsWith('.'))) return false
82
+ if (parts.length === 1) return parts[0] === 'library.json' || !/\.\w+$/.test(parts[0]!)
83
+ return true
84
+ }
@@ -0,0 +1,86 @@
1
+ import { normalize } from 'node:path'
2
+
3
+ import { refusedBlueprintItems, type BuildEntry } from './local-applied'
4
+ import { lockRefOf, type Unchanged } from './local-check-inherited'
5
+ import { RUNTIME_DOWN_ERROR } from './local-runtime-health'
6
+
7
+ /** One item that did not apply, for `local dev --once --json`: the item and why. */
8
+ export interface ApplyFailure {
9
+ item: string
10
+ error: string
11
+ /** The item as `local status` names it (`function/staff-availability`, `action/holdBooking`), when known. */
12
+ ref?: string
13
+ /** The project file that was applied, when the failure is a target's. */
14
+ file?: string
15
+ }
16
+
17
+ /**
18
+ * What `local dev --once` fails with, or null when everything applied: each
19
+ * target that failed, and each item the runtime refused inside a Blueprint
20
+ * that applied (a governed Action, a metric, a link type). Those are the
21
+ * failures `local status` and `local check` count, so the three say one
22
+ * number. `blueprint` is the Blueprint's build entry for the files as they
23
+ * are now; a Blueprint that failed as a whole is one failure, not its parts.
24
+ */
25
+ export function applyFailures(failing: ReadonlyMap<string, ApplyFailure>, targetCount: number, blueprint: BuildEntry | undefined, refOf?: (key: string) => { ref: string; file?: string } | undefined): { message: string; failed: ApplyFailure[] } | null {
26
+ const refused = failing.has('blueprint') || !blueprint ? [] : refusedBlueprintItems(blueprint)
27
+ const named = (key: string, f: ApplyFailure): ApplyFailure => {
28
+ const at = refOf?.(key)
29
+ return at ? { ...f, ref: at.ref, ...(at.file ? { file: at.file } : {}) } : f
30
+ }
31
+ const failed = [...[...failing].map(([key, f]) => named(key, f)), ...refused.map((ref) => ({ item: ref, error: refusedReason(blueprint!, ref), ...(refOf ? { ref } : {}) }))]
32
+ if (!failed.length) return null
33
+ const message = refused.length
34
+ ? `${failed.length} item${failed.length === 1 ? '' : 's'} didn’t apply: ${failing.size ? `${failing.size} of ${targetCount} failed, and ` : ''}the runtime refused ${refused.length} in the Blueprint (${refused.join(', ')})`
35
+ : `${failing.size} of ${targetCount} failed to apply`
36
+ return { message, failed }
37
+ }
38
+
39
+ function refusedReason(entry: BuildEntry, ref: string): string {
40
+ const [kind, name] = [ref.slice(0, ref.indexOf('/')), ref.slice(ref.indexOf('/') + 1)]
41
+ const why = kind === 'action' ? entry.actions?.[name]?.reason : kind === 'objectType' ? entry.objects?.[name]?.refused : entry.parts?.[ref]?.reason
42
+ return (why ?? 'the local runtime refused it').replace(/^(?!the local runtime refused it)/, 'the local runtime refused it: ')
43
+ }
44
+
45
+ /**
46
+ * The failures of an apply, split as `local check` splits them: inherited
47
+ * when the item is still byte for byte what the last pull wrote (its
48
+ * `frontera.lock` entry), and so is the file that was applied, so the change
49
+ * did not make it; made otherwise (an item changed or added here, a file
50
+ * added to a pulled item, the Blueprint as a whole, a failure with no `ref`). A failure that is the runtime's (it stopped answering, an item
51
+ * re-applied in a loop) is never inherited: it is not the workspace's.
52
+ * `strict` counts every failure as made.
53
+ */
54
+ export function splitApplyFailures(failed: readonly ApplyFailure[], unchanged: Unchanged, opts: { strict?: boolean } = {}): { made: ApplyFailure[]; inherited: ApplyFailure[] } {
55
+ const made: ApplyFailure[] = []
56
+ const inherited: ApplyFailure[] = []
57
+ for (const f of failed) {
58
+ const runtimes = f.error === RUNTIME_DOWN_ERROR || /applied \d+ times with no change to its files/.test(f.error)
59
+ const asPulled = !!f.ref && unchanged.refs.has(lockRefOf(f.ref)) && (!f.file || unchanged.files.has(normalize(f.file)))
60
+ if (!opts.strict && !runtimes && asPulled) inherited.push(f)
61
+ else made.push(f)
62
+ }
63
+ return { made, inherited }
64
+ }
65
+
66
+ /** The one line an apply whose every failure is inherited ends with. */
67
+ export function inheritedApplyLine(count: number): string {
68
+ return `${count} inherited failure${count === 1 ? '' : 's'} in items unchanged since the pull: they fail as they do in the workspace, and nothing changed or added here failed (\`frontera local check --strict\` fails on them)`
69
+ }
70
+
71
+ /**
72
+ * The items `local dev --once --json` listed as inherited failures, by
73
+ * reference: from its result when it exited 0, or from its error's details
74
+ * when something changed or added failed too. Empty when it listed none.
75
+ */
76
+ export function inheritedRefsOf(data: unknown): Set<string> {
77
+ const record = (v: unknown): v is Record<string, unknown> => !!v && typeof v === 'object' && !Array.isArray(v)
78
+ const from = record(data) ? (Array.isArray(data.inheritedFailures) ? data.inheritedFailures : record(data.details) && Array.isArray(data.details.inheritedFailures) ? data.details.inheritedFailures : []) : []
79
+ return new Set(from.flatMap((f) => (record(f) && typeof f.ref === 'string' ? [f.ref] : [])))
80
+ }
81
+
82
+ /** What one apply came to, in a line, for a caller that reads `ok` (the MCP `apply`). */
83
+ export function applySummaryLine(ok: boolean, made: number, inherited: number): string {
84
+ if (!ok) return made ? `${made} item${made === 1 ? '' : 's'} changed or added here didn’t apply${inherited ? `, and ${inheritedApplyLine(inherited).replace(/: they fail.*$/, '')}` : ''}` : 'the apply failed: see log'
85
+ return inherited ? `Everything changed or added here applied; ${inheritedApplyLine(inherited)}` : 'Everything applied'
86
+ }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * A guard on `local dev` re-applying an item whose files did not change.
3
+ *
4
+ * An unchanged item is applied again only for a reason that settles after one
5
+ * apply: its draft was published, a governed Action it deploys isn't running,
6
+ * a dataset it reads got a new revision, an Action it names was authored
7
+ * again. A reason that never settles is a bug, and it re-applies the item
8
+ * forever (a refused Action did, every few seconds, until b57501e3c). So an
9
+ * item applied `limit` times with the same content hash, no file event for
10
+ * it, within one drain or with less than `gapMs` between the applies, is not
11
+ * applied again until its files change.
12
+ *
13
+ * Only applies that succeeded count. A failed item is retried only after
14
+ * something else applied, so failures are bounded by the successes this
15
+ * guards; counting them would stop an item that is waiting, legitimately, for
16
+ * a slow dependency.
17
+ */
18
+
19
+ export const REAPPLY_LIMIT = 3
20
+ /** The longest gap between two applies that still counts them as one run of re-applies. */
21
+ export const REAPPLY_GAP_MS = 20_000
22
+
23
+ interface Streak {
24
+ hash: string | null
25
+ /** Successful applies of this content since the streak began. */
26
+ applied: number
27
+ lastAt: number
28
+ drain: number
29
+ }
30
+
31
+ export type WatchdogVerdict = 'apply' | 'stop' | 'stopped'
32
+
33
+ export class ApplyWatchdog {
34
+ private readonly streaks = new Map<string, Streak>()
35
+ /** Items stopped, by the content hash they were stopped at. */
36
+ private readonly stopped = new Map<string, string | null>()
37
+ private readonly touched = new Set<string>()
38
+
39
+ constructor(
40
+ private readonly limit = REAPPLY_LIMIT,
41
+ private readonly gapMs = REAPPLY_GAP_MS,
42
+ private readonly now: () => number = Date.now,
43
+ ) {}
44
+
45
+ /** A file event for the item: whatever it re-applied before starts over. */
46
+ fileEvent(key: string): void {
47
+ this.touched.add(key)
48
+ this.stopped.delete(key)
49
+ }
50
+
51
+ /**
52
+ * Before applying `key` at `hash` in drain number `drain`: `apply` to go
53
+ * ahead, `stop` the first time it is held back (say it), `stopped` after.
54
+ */
55
+ before(key: string, hash: string | null, drain: number): WatchdogVerdict {
56
+ const at = this.now()
57
+ if (this.stopped.has(key)) {
58
+ if (this.stopped.get(key) === hash) return 'stopped'
59
+ this.stopped.delete(key)
60
+ }
61
+ const fresh = this.touched.delete(key)
62
+ let streak = this.streaks.get(key)
63
+ if (!streak || fresh || streak.hash !== hash || (streak.drain !== drain && at - streak.lastAt > this.gapMs)) {
64
+ streak = { hash, applied: 0, lastAt: at, drain }
65
+ this.streaks.set(key, streak)
66
+ }
67
+ if (streak.applied >= this.limit) {
68
+ this.stopped.set(key, hash)
69
+ this.streaks.delete(key)
70
+ return 'stop'
71
+ }
72
+ streak.lastAt = at
73
+ streak.drain = drain
74
+ return 'apply'
75
+ }
76
+
77
+ /** After the apply `before` let through. */
78
+ after(key: string, ok: boolean): void {
79
+ const streak = this.streaks.get(key)
80
+ if (!streak) return
81
+ if (ok) streak.applied += 1
82
+ streak.lastAt = this.now()
83
+ }
84
+
85
+ /** How many times an item was applied before it was stopped: for the line that says so. */
86
+ get applyLimit(): number {
87
+ return this.limit
88
+ }
89
+ }
90
+
91
+ /** The line `local dev` says, and records, when it stops re-applying an item. */
92
+ export function stoppedReapplyingLine(what: string, times: number): string {
93
+ const name = what.charAt(0).toUpperCase() + what.slice(1)
94
+ return `${name} applied ${times} times with no change to its files: stopped re-applying it; this is a bug, please report it with \`frontera local logs\``
95
+ }