@frontera-sdk/cli 1.52.39 → 1.52.41

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (235) hide show
  1. package/README.md +9 -0
  2. package/package.json +6 -4
  3. package/src/adopt.ts +6 -2
  4. package/src/api/apps-api.ts +86 -4
  5. package/src/api/blueprint-authoring-api.ts +35 -11
  6. package/src/api/dataset-api.ts +29 -0
  7. package/src/api/governed-action-api.ts +90 -2
  8. package/src/api/platform-api.ts +87 -2
  9. package/src/api/workflow-api.ts +33 -2
  10. package/src/app-contract.ts +156 -0
  11. package/src/app-lock.ts +125 -0
  12. package/src/args.ts +14 -3
  13. package/src/auth-verify.ts +10 -0
  14. package/src/automation-template.ts +12 -1
  15. package/src/blueprint/action-rules.ts +516 -0
  16. package/src/blueprint/action-semantic-fit.ts +205 -0
  17. package/src/blueprint/compile.ts +51 -2
  18. package/src/blueprint/diff.ts +34 -2
  19. package/src/blueprint/metric-query.ts +215 -0
  20. package/src/blueprint/metric-rules.ts +122 -0
  21. package/src/blueprint/object-sets.ts +116 -0
  22. package/src/blueprint/ontology-edit-plan.ts +49 -5
  23. package/src/blueprint/projection.ts +24 -1
  24. package/src/blueprint/scaffold.ts +84 -42
  25. package/src/commands/action/available.ts +1 -1
  26. package/src/commands/action/criteria-refusal.ts +84 -0
  27. package/src/commands/action/decide.ts +5 -2
  28. package/src/commands/action/deploy.ts +13 -4
  29. package/src/commands/action/list.ts +134 -19
  30. package/src/commands/action/requests.ts +34 -6
  31. package/src/commands/action/submit.ts +28 -13
  32. package/src/commands/agent/draft-changes.ts +1 -1
  33. package/src/commands/agent/index-commands.ts +69 -7
  34. package/src/commands/agent/resolve.ts +2 -0
  35. package/src/commands/app/checks.ts +44 -0
  36. package/src/commands/app/deploy.ts +243 -60
  37. package/src/commands/app/deployments.ts +97 -0
  38. package/src/commands/app/dev.ts +103 -11
  39. package/src/commands/app/init.ts +36 -3
  40. package/src/commands/app/promote.ts +45 -19
  41. package/src/commands/app/pull.ts +3 -1
  42. package/src/commands/app/rollback.ts +46 -0
  43. package/src/commands/app/sdk.ts +54 -8
  44. package/src/commands/app/unpin.ts +27 -0
  45. package/src/commands/app/versions.ts +1 -1
  46. package/src/commands/automation/bindings.ts +4 -2
  47. package/src/commands/automation/build-entry.ts +4 -1
  48. package/src/commands/automation/index-commands.ts +3 -1
  49. package/src/commands/automation/pull.ts +6 -1
  50. package/src/commands/automation/run.ts +34 -9
  51. package/src/commands/blueprint/bind.ts +2 -1
  52. package/src/commands/blueprint/declarative.ts +332 -32
  53. package/src/commands/blueprint/editable.ts +3 -3
  54. package/src/commands/blueprint/get.ts +6 -2
  55. package/src/commands/blueprint/grants.ts +64 -10
  56. package/src/commands/blueprint/metric.ts +108 -0
  57. package/src/commands/blueprint/object-sets.ts +71 -0
  58. package/src/commands/blueprint/query.ts +155 -31
  59. package/src/commands/dataset/index-commands.ts +156 -7
  60. package/src/commands/knowledge/index-commands.ts +122 -20
  61. package/src/commands/local/check.ts +657 -44
  62. package/src/commands/local/data.ts +33 -12
  63. package/src/commands/local/dev.ts +876 -89
  64. package/src/commands/local/docs-mcp.ts +10 -18
  65. package/src/commands/local/index-commands.ts +300 -63
  66. package/src/commands/local/init.ts +18 -5
  67. package/src/commands/local/key.ts +0 -3
  68. package/src/commands/local/providers.ts +0 -7
  69. package/src/commands/local/remove.ts +116 -0
  70. package/src/commands/local/schema.ts +3 -4
  71. package/src/commands/local/sdk-refresh.ts +59 -0
  72. package/src/commands/local/snapshot.ts +100 -0
  73. package/src/commands/local/sync-request.ts +321 -14
  74. package/src/commands/local/sync.ts +103 -6
  75. package/src/commands/mcp/serve.ts +34 -0
  76. package/src/commands/registry.ts +54 -10
  77. package/src/commands/types.ts +29 -0
  78. package/src/commands/workflow/archive.ts +46 -0
  79. package/src/commands/workflow/get.ts +2 -1
  80. package/src/commands/workflow/index-commands.ts +5 -0
  81. package/src/commands/workflow/push.ts +49 -2
  82. package/src/commands/workflow/run.ts +65 -7
  83. package/src/commands/workflow/runs.ts +9 -6
  84. package/src/commands/workflow/webhook-test.ts +166 -0
  85. package/src/config.ts +43 -6
  86. package/src/dev-broker.ts +52 -0
  87. package/src/exit.ts +103 -4
  88. package/src/flag-help.ts +77 -17
  89. package/src/forge/claude-commands.ts +11 -6
  90. package/src/forge/claude-skills.ts +4 -4
  91. package/src/forge/touches.ts +15 -12
  92. package/src/function-outside-imports.ts +89 -0
  93. package/src/harness.ts +42 -24
  94. package/src/heal.ts +113 -2
  95. package/src/kit.ts +1 -1
  96. package/src/local/fix-words.ts +12 -0
  97. package/src/local/function-bundle-recovery.ts +775 -0
  98. package/src/local/local-actions.ts +103 -10
  99. package/src/local/local-agent-action-grants.ts +210 -0
  100. package/src/local/local-agent-checks.ts +232 -0
  101. package/src/local/local-agent-grants.ts +29 -7
  102. package/src/local/local-agent-lean-plugins.ts +68 -0
  103. package/src/local/local-agent-missing-tools.ts +68 -0
  104. package/src/local/local-agent-name.ts +27 -0
  105. package/src/local/local-app-code.ts +191 -0
  106. package/src/local/local-app-dev-files.ts +186 -0
  107. package/src/local/local-app-dev-session.ts +88 -0
  108. package/src/local/local-app-lock-install.ts +114 -0
  109. package/src/local/local-app-pulled.ts +167 -0
  110. package/src/local/local-app-restore.ts +111 -0
  111. package/src/local/local-app-runtime.ts +68 -0
  112. package/src/local/local-app-sdk-paths.ts +149 -0
  113. package/src/local/local-app-sdk.ts +509 -0
  114. package/src/local/local-applied.ts +212 -22
  115. package/src/local/local-apply-history.ts +84 -0
  116. package/src/local/local-apply-summary.ts +86 -0
  117. package/src/local/local-apply-watchdog.ts +95 -0
  118. package/src/local/local-apps.ts +181 -8
  119. package/src/local/local-blueprint-errors.ts +198 -0
  120. package/src/local/local-blueprint-outline.ts +31 -12
  121. package/src/local/local-blueprint-schemas.ts +582 -0
  122. package/src/local/local-build-inputs.ts +48 -0
  123. package/src/local/local-build-summary.ts +495 -28
  124. package/src/local/local-case-evidence.ts +120 -0
  125. package/src/local/local-case-not-run.ts +153 -0
  126. package/src/local/local-chat-apps.ts +93 -16
  127. package/src/local/local-check-inherited.ts +83 -0
  128. package/src/local/local-cities.ts +89 -0
  129. package/src/local/local-component-health.ts +119 -0
  130. package/src/local/local-control.ts +34 -7
  131. package/src/local/local-db-snapshot.ts +348 -0
  132. package/src/local/local-dev-references.ts +347 -36
  133. package/src/local/local-dev-sync.ts +141 -12
  134. package/src/local/local-docker.ts +35 -1
  135. package/src/local/local-docs-search.ts +1 -1
  136. package/src/local/local-docs.ts +281 -2
  137. package/src/local/local-empty-workflows.ts +92 -0
  138. package/src/local/local-env.ts +16 -1
  139. package/src/local/local-file-checks.ts +104 -0
  140. package/src/local/local-function-ids.ts +141 -0
  141. package/src/local/local-function-recovery.ts +141 -0
  142. package/src/local/local-function-state.ts +87 -0
  143. package/src/local/local-keys.ts +7 -5
  144. package/src/local/local-knowledge-documents.ts +1 -1
  145. package/src/local/local-knowledge-ingestion.ts +225 -0
  146. package/src/local/local-knowledge-uploads.ts +217 -0
  147. package/src/local/local-lessons.ts +1 -1
  148. package/src/local/local-library.ts +1 -1
  149. package/src/local/local-media-placeholders.ts +182 -0
  150. package/src/local/local-media-sets.ts +90 -10
  151. package/src/local/local-model-parameters.ts +89 -0
  152. package/src/local/local-object-sets.ts +18 -6
  153. package/src/local/local-operating.ts +35 -1
  154. package/src/local/local-org-settings.ts +206 -8
  155. package/src/local/local-orphans.ts +321 -0
  156. package/src/local/local-personas.ts +296 -0
  157. package/src/local/local-portability.ts +143 -18
  158. package/src/local/local-ports.ts +99 -7
  159. package/src/local/local-project-notices.ts +234 -0
  160. package/src/local/local-project-secrets.ts +133 -0
  161. package/src/local/local-project.ts +12 -0
  162. package/src/local/local-provider-refusal.ts +66 -0
  163. package/src/local/local-request-evidence.ts +112 -0
  164. package/src/local/local-runtime-health.ts +123 -0
  165. package/src/local/local-runtime-history.ts +50 -0
  166. package/src/local/local-runtime-target.ts +42 -0
  167. package/src/local/local-sample-data.ts +141 -0
  168. package/src/local/local-sample-remake.ts +138 -0
  169. package/src/local/local-scaffold.ts +336 -53
  170. package/src/local/local-schema-check.ts +148 -0
  171. package/src/local/local-schemas.ts +742 -25
  172. package/src/local/local-secret-carry.ts +107 -0
  173. package/src/local/local-secrets.ts +41 -11
  174. package/src/local/local-seed.ts +102 -26
  175. package/src/local/local-stand-ins.ts +3 -3
  176. package/src/local/local-state.ts +43 -2
  177. package/src/local/local-studio-server.ts +170 -7
  178. package/src/local/local-supervisor.ts +44 -6
  179. package/src/local/local-sync-merge.ts +1 -1
  180. package/src/local/local-sync-names.ts +2 -0
  181. package/src/local/local-sync-plan.ts +19 -8
  182. package/src/local/local-sync-push.ts +15 -12
  183. package/src/local/local-sync-refs.ts +49 -3
  184. package/src/local/local-sync-remote.ts +349 -38
  185. package/src/local/local-sync-snapshot.ts +18 -13
  186. package/src/local/local-synthetic-data.ts +2116 -99
  187. package/src/local/local-test-baseline.ts +113 -0
  188. package/src/local/local-test-cases.ts +76 -1
  189. package/src/local/local-test-runner.ts +582 -91
  190. package/src/local/local-test-runs.ts +13 -5
  191. package/src/local/local-vendored-sdk.ts +183 -0
  192. package/src/local/local-workflow-state.ts +16 -3
  193. package/src/local/local-workflow-tests.ts +482 -0
  194. package/src/local/local-workspace-grants.ts +81 -0
  195. package/src/main.ts +50 -9
  196. package/src/mcp/a11y-scan.ts +55 -0
  197. package/src/mcp/browser/ax-snapshot.ts +181 -0
  198. package/src/mcp/browser/credentials.ts +78 -0
  199. package/src/mcp/browser/frames.ts +336 -0
  200. package/src/mcp/browser/headless-session.ts +413 -0
  201. package/src/mcp/browser/headless-tab.ts +575 -0
  202. package/src/mcp/browser/keys.ts +70 -0
  203. package/src/mcp/browser/page-scripts.ts +129 -0
  204. package/src/mcp/browser/wait.ts +73 -0
  205. package/src/mcp/forge-link.ts +126 -0
  206. package/src/mcp/headless.ts +199 -0
  207. package/src/mcp/mcp-apps.ts +435 -0
  208. package/src/mcp/mcp-chat.ts +226 -0
  209. package/src/mcp/mcp-cli.ts +53 -0
  210. package/src/mcp/mcp-protocol.ts +122 -0
  211. package/src/mcp/mcp-runtime.ts +113 -0
  212. package/src/mcp/mcp-server.ts +108 -0
  213. package/src/mcp/mcp-traces.ts +409 -0
  214. package/src/mcp/page-observation.ts +193 -0
  215. package/src/mcp/tools/agent-tools.ts +700 -0
  216. package/src/mcp/tools/app-tools.ts +241 -0
  217. package/src/mcp/tools/browser-tools.ts +310 -0
  218. package/src/mcp/tools/docs-tools.ts +32 -0
  219. package/src/mcp/tools/project-tools.ts +205 -0
  220. package/src/mcp/tools/test-tools.ts +339 -0
  221. package/src/mcp/tools/workflow-tools.ts +611 -0
  222. package/src/output.ts +1 -1
  223. package/src/pack.ts +8 -3
  224. package/src/packaging.ts +1 -1
  225. package/src/project-bootstrap.ts +107 -4
  226. package/src/project-context.ts +18 -1
  227. package/src/project.ts +27 -2
  228. package/src/provenance.ts +25 -0
  229. package/src/render-evidence.ts +65 -7
  230. package/src/scopes.ts +1 -1
  231. package/src/template.ts +14 -8
  232. package/src/templates/next-app-files.ts +35 -6
  233. package/src/templates/next-skills.ts +61 -27
  234. package/src/vendor/kit-assets.json +6 -6
  235. package/src/vendor/sdk-sources.json +17 -14
@@ -6,7 +6,7 @@ import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'
6
6
  import type { LocalAction } from './local-actions'
7
7
  import { absentWhy, adaptOperating, plainLinks, projectProvides } from './local-adapt'
8
8
  import { applyAgentGrants, type AgentGrant } from './local-agent-grants'
9
- import { oneLine, refWords } from './local-applied'
9
+ import { loadBuild, oneLine, pushedWorkflowVersion, refWords } from './local-applied'
10
10
  import { readableName } from './local-sync-names'
11
11
  import { ensureObjectDefinitions } from './local-objects'
12
12
  import { applyQualityChecks, QUALITY_CHECKS_FILE, readQualityChecks, sourcePaths, sourceRows } from './local-quality-checks'
@@ -14,14 +14,21 @@ import { readLock } from './local-sync-plan'
14
14
  import { applyErrorText } from './local-apply-error'
15
15
  import { localAdaptation } from './local-blueprint-outline'
16
16
  import { actionFit, classify, objectTypeFit, objectTypeNeeds } from './local-portability'
17
+ import { actionSemanticConflicts, semanticFactsOf } from '../blueprint/action-semantic-fit'
18
+ import { inheritedConflictLine } from '../blueprint/action-rules'
19
+ import { inheritedFromPull, unchangedSincePull } from './local-check-inherited'
17
20
  import type { SyncProfiles, SyncTarget } from './local-dev-sync'
18
21
  import type { LocalProject } from './local-project'
19
22
  import { syncClient } from './local-sync-client'
23
+ import { DEFAULT_KNOWLEDGE_THRESHOLD, KEYLESS_KNOWLEDGE_THRESHOLD, PLATFORM_DEFAULT_EMBEDDING, sameEmbedding, WIDENED_KNOWLEDGE_THRESHOLD, type OrgSettings } from './local-org-settings'
24
+ import { isLocalEmbeddings } from './local-seed'
25
+ import { readLink } from './local-sync-link'
26
+ import { readSnapshot } from './local-sync-snapshot'
20
27
  import { readProviderSetup } from './local-providers'
21
28
  import { fillPluginSecrets, localSecretNames, needsValueLine, NON_SECRET_HEADERS, pluginSecretInputs, pluginSecretName } from './local-secrets'
22
29
  import { describeUnresolved, governedActionIds, isUuid, kindsIn, parseRef, referencesIn, toIds, type Directory } from './local-sync-refs'
23
30
  import { buildDirectory, rows } from './local-sync-remote'
24
- import type { LocalCredentials } from './local-state'
31
+ import { isAlive, type LocalCredentials } from './local-state'
25
32
  import { addStandInServers, dropPlatformOnly, dropStandInTriggers, platformOnlyNames, standInNames } from './local-stand-ins'
26
33
 
27
34
  /**
@@ -441,12 +448,43 @@ function parseJson<T>(output: string): T | null {
441
448
  }
442
449
  }
443
450
 
451
+ /**
452
+ * Why a dataset file is not applied here, when an object type over a Media
453
+ * Set reads it (`media_<set>`, or a type with a media property left off):
454
+ * that type reads a Media Set's catalog, which an empty stand-in Media Set
455
+ * holds here (or nothing, for a type left off), and a CSV of sample rows
456
+ * cannot publish into it ("Only managed Datasets accept file publications").
457
+ * Null for any other dataset.
458
+ */
459
+ export function mediaCatalogDatasetReason(root: string, dataset: string): string | null {
460
+ const dir = join(root, 'blueprint', 'object-types')
461
+ if (!existsSync(dir)) return null
462
+ for (const name of readdirSync(dir).filter((n) => /\.ya?ml$/.test(n) && !n.startsWith('.'))) {
463
+ let doc: Record<string, any> | null
464
+ try {
465
+ doc = parseYaml(readFileSync(join(dir, name), 'utf8')) as Record<string, any> | null
466
+ } catch {
467
+ continue
468
+ }
469
+ if (!doc || doc.backing?.dataset !== dataset) continue
470
+ const fit = localAdaptation(doc, () => false)
471
+ if (fit.mediaStub) return `${doc.apiName ?? name} reads it as Media Set ${fit.mediaStub}'s catalog, which an empty Media Set stands in for here (its files stay on the platform); a CSV can't publish into a Media Set's catalog, so datasets/${dataset}.* is left as it is and not applied`
472
+ if (fit.leftOff) return `${doc.apiName ?? name} reads the platform's media files and is left off this runtime, so datasets/${dataset}.* is not applied`
473
+ }
474
+ return null
475
+ }
476
+
444
477
  /** How the runtime holds one object type of the Blueprint it was given. */
445
478
  export interface ObjectBuild {
446
479
  status: 'built' | 'adapted' | 'stubbed' | 'left-off'
447
480
  adaptations?: string[]
481
+ /** Left off because the local runtime refused this type's file: the runtime's words. The rest of the Blueprint builds. */
482
+ refused?: string
448
483
  }
449
484
 
485
+ /** A Blueprint artifact the local runtime refused, by `kind:apiName` (`object-type:Receipt`), with its words. */
486
+ export type BlueprintRefusals = ReadonlyMap<string, string>
487
+
450
488
  export interface ResolvedBlueprint {
451
489
  /** The runtime's copy, when the project's files needed changing; unset otherwise. */
452
490
  dir?: string
@@ -457,13 +495,29 @@ export interface ResolvedBlueprint {
457
495
  actions: LocalAction[]
458
496
  /** Actions left off the copy, with why: they act on what the runtime leaves off. */
459
497
  leftOffActions: Record<string, string>
498
+ /** The Actions of `leftOffActions` the runtime refused (their own file), not left off by design. */
499
+ refusedActions?: string[]
500
+ /**
501
+ * Each Action given to the runtime as the workspace runs it although it offers
502
+ * a value the semantic type it writes refuses (`inheritedFromPull`), with the
503
+ * warning lines: it deploys, and the build and `local check` say them as inherited.
504
+ */
505
+ inheritedWarnings?: Record<string, string[]>
506
+ /**
507
+ * The link types, metrics, semantic types and shared fields left off the copy, by the
508
+ * reference `status` lists them as (`metric/workOrdersRaised`): with why, and whether the
509
+ * runtime refused the file itself (a failure to fix) or it names a type left off.
510
+ */
511
+ parts?: Record<string, { reason: string; refused: boolean }>
460
512
  /** The Media Sets the runtime needs, empty, for the media-backed types it holds: API name → display name. */
461
513
  mediaSets?: Record<string, string>
514
+ /** Each type over a Media Set's catalog: its set, and its file in the runtime's copy (`object-types/Receipt.yaml`). */
515
+ mediaTypes?: Record<string, { set: string; file: string }>
462
516
  }
463
517
 
464
518
  /**
465
519
  * The Blueprint as this runtime can hold it, in a copy under
466
- * `.frontera/local/resolved/blueprint`; the project's files stay as pulled.
520
+ * `.frontera/local/resolved/run-<pid>/blueprint`; the project's files stay as pulled.
467
521
  * The rules are `objectTypeFit` and `actionFit` (`local-portability.ts`):
468
522
  * - An object type backed by the platform's media files (a `media_reference`
469
523
  * property) cannot be read from a local file dataset: it is left out, with
@@ -471,13 +525,16 @@ export interface ResolvedBlueprint {
471
525
  * - An object type whose rows are read by markings, with no markings column
472
526
  * named, relies on a column the platform's media datasets publish; over a
473
527
  * local file dataset it reads with organization row access instead.
474
- * - A calculated property is left off the type, and an Action that sets it.
528
+ * - A calculated property other than a location is left off the type, and an
529
+ * Action that sets it (a location is added by `blueprint apply`).
475
530
  * Each says so. `dir` is unset when nothing needed changing.
476
531
  */
477
- export function resolveBlueprint(project: LocalProject): ResolvedBlueprint {
532
+ export function resolveBlueprint(project: LocalProject, refused: BlueprintRefusals = new Map()): ResolvedBlueprint {
478
533
  const source = join(project.root, 'blueprint')
479
534
  const types = join(source, 'object-types')
480
535
  const none: ResolvedBlueprint = { warnings: [], objects: {}, actions: [], leftOffActions: {} }
536
+ const parts: Record<string, { reason: string; refused: boolean }> = {}
537
+ const offWords = (t: string) => (objects[t]?.refused ? `it names ${t}, which the local runtime refused` : `it names ${t}, which reads the platform’s media files`)
481
538
  if (!existsSync(types)) return none
482
539
  const yamlIn = (dir: string) => (existsSync(join(source, dir)) ? readdirSync(join(source, dir)).filter((n) => /\.ya?ml$/.test(n) && !n.startsWith('.')) : [])
483
540
  const read = (dir: string, name: string) => {
@@ -495,12 +552,22 @@ export function resolveBlueprint(project: LocalProject): ResolvedBlueprint {
495
552
  const warnings: string[] = []
496
553
  const objects: Record<string, ObjectBuild> = {}
497
554
  const mediaSets: Record<string, string> = {}
555
+ const mediaTypes: Record<string, { set: string; file: string }> = {}
498
556
  const hasLocalDataset = (dataset: string) => existsSync(join(project.root, 'datasets', `${dataset}.json`))
499
557
  for (const name of yamlIn('object-types')) {
500
558
  const doc = read('object-types', name)
501
559
  if (!doc || typeof doc.apiName !== 'string') continue
502
560
  const fit = localAdaptation(doc, hasLocalDataset)
503
561
  const adaptations = classify(objectTypeNeeds(doc, hasLocalDataset)).adaptations
562
+ // The runtime refused this type's file on an earlier try: the rest of the Blueprint builds without it.
563
+ const refusal = refused.get(`object-type:${doc.apiName}`)
564
+ if (refusal) {
565
+ dropped.add(doc.apiName)
566
+ removed.push(join('object-types', name))
567
+ objects[doc.apiName] = { status: 'left-off', adaptations: [`is left off: the local runtime refused it: ${refusal}`], refused: refusal }
568
+ warnings.push(` warning: ${doc.apiName} is left off this runtime: the runtime refused it: ${refusal}`)
569
+ continue
570
+ }
504
571
  if (fit.leftOff) {
505
572
  dropped.add(doc.apiName)
506
573
  removed.push(join('object-types', name))
@@ -514,17 +581,19 @@ export function resolveBlueprint(project: LocalProject): ResolvedBlueprint {
514
581
  // A property's set is the platform's id; the runtime derives its own from the dataset.
515
582
  if (fit.mediaStub) {
516
583
  mediaSets[fit.mediaStub] ??= readableName(fit.mediaStub)
584
+ mediaTypes[doc.apiName] = { set: fit.mediaStub, file: join('object-types', name) }
517
585
  const properties = (doc.properties as Array<Record<string, unknown>> | undefined) ?? []
518
586
  next = { ...next, properties: properties.map(({ mediaSetId: _set, ...p }) => p) }
519
- warnings.push(` warning: ${doc.apiName}’s files stay on the platform: an empty Media Set, ${fit.mediaStub}, stands in for them here`)
587
+ warnings.push(` warning: ${doc.apiName}’s files stay on the platform: a stand-in Media Set, ${fit.mediaStub}, holds placeholder files for them here`)
520
588
  }
521
- // A calculated property (a location from latitude and longitude) reads no column, and
522
- // `blueprint apply` cannot create one yet: the runtime holds the type without it.
589
+ // A location (from latitude and longitude) is added by `blueprint apply` after the type. Any
590
+ // other calculation has no route from a file: the runtime holds the type without it.
523
591
  if (fit.calculated.length) {
524
- const properties = (doc.properties as Array<{ calculation?: unknown }> | undefined) ?? []
525
- next = { ...next, properties: properties.filter((p) => !p.calculation) }
592
+ const off = new Set(fit.calculated)
593
+ const properties = (doc.properties as Array<{ apiName?: string; calculation?: unknown }> | undefined) ?? []
594
+ next = { ...next, properties: properties.filter((p) => !off.has(String(p.apiName))) }
526
595
  for (const f of fit.calculated) droppedFields.add(`${doc.apiName}.${f}`)
527
- warnings.push(` warning: ${doc.apiName}.${fit.calculated.join(`, ${doc.apiName}.`)} is left off this runtime: calculated fields are not applied locally yet`)
596
+ warnings.push(` warning: ${doc.apiName}.${fit.calculated.join(`, ${doc.apiName}.`)} is left off this runtime: only a location is a calculated field a file can add`)
528
597
  }
529
598
  if (fit.readsEveryRow) {
530
599
  next = { ...next, rowAccess: 'organization' }
@@ -532,17 +601,48 @@ export function resolveBlueprint(project: LocalProject): ResolvedBlueprint {
532
601
  }
533
602
  if (next !== doc) patched.set(join('object-types', name), stringifyYaml(next, { lineWidth: 0 }))
534
603
  }
535
- if (dropped.size) {
536
- for (const name of yamlIn('link-types')) {
537
- const doc = read('link-types', name)
538
- if (dropped.has(doc?.from?.objectType) || dropped.has(doc?.to?.objectType)) removed.push(join('link-types', name))
539
- }
540
- for (const name of yamlIn('metrics')) {
541
- if (dropped.has(read('metrics', name)?.objectType)) removed.push(join('metrics', name))
604
+ // A link type or metric the runtime refused, or that names a type left off, goes too; a refused
605
+ // semantic type or shared field is taken out, and what names it is refused (and left off) next.
606
+ const refusedHere = (kind: string, doc: Record<string, any> | null) => (typeof doc?.apiName === 'string' ? refused.get(`${kind}:${doc.apiName}`) : undefined)
607
+ for (const name of yamlIn('link-types')) {
608
+ const doc = read('link-types', name)
609
+ const refusal = refusedHere('link-type', doc)
610
+ if (refusal) warnings.push(` warning: link type ${doc!.apiName} is left off this runtime: the runtime refused it: ${refusal}`)
611
+ const offType = [doc?.from?.objectType, doc?.to?.objectType].find((t) => typeof t === 'string' && dropped.has(t)) as string | undefined
612
+ if (refusal || offType) removed.push(join('link-types', name))
613
+ if (typeof doc?.apiName === 'string' && (refusal || offType)) parts[`linkType/${doc.apiName}`] = refusal ? { reason: `the local runtime refused it: ${refusal}`, refused: true } : { reason: offWords(offType!), refused: false }
614
+ }
615
+ for (const name of yamlIn('metrics')) {
616
+ const doc = read('metrics', name)
617
+ const refusal = refusedHere('metric', doc)
618
+ if (refusal) warnings.push(` warning: metric ${doc!.apiName} is left off this runtime: the runtime refused it: ${refusal}`)
619
+ if (refusal || dropped.has(doc?.objectType)) removed.push(join('metrics', name))
620
+ if (typeof doc?.apiName === 'string' && (refusal || dropped.has(doc?.objectType))) parts[`metric/${doc.apiName}`] = refusal ? { reason: `the local runtime refused it: ${refusal}`, refused: true } : { reason: offWords(String(doc.objectType)), refused: false }
621
+ }
622
+ for (const [kind, folder] of [['semantic-type', 'semantic-types'], ['shared-field', 'shared-fields']] as const) {
623
+ for (const name of yamlIn(folder)) {
624
+ const doc = read(folder, name)
625
+ const refusal = refusedHere(kind, doc)
626
+ if (!refusal) continue
627
+ removed.push(join(folder, name))
628
+ warnings.push(` warning: ${kind.replace('-', ' ')} ${doc!.apiName} is left off this runtime: the runtime refused it: ${refusal}`)
629
+ parts[`${kind === 'semantic-type' ? 'semanticType' : 'sharedField'}/${doc!.apiName}`] = { reason: `the local runtime refused it: ${refusal}`, refused: true }
542
630
  }
543
631
  }
544
632
  const actions: LocalAction[] = []
545
633
  const leftOffActions: Record<string, string> = {}
634
+ const refusedActions: string[] = []
635
+ // A value an Action offers that the semantic type it writes refuses, in an Action and files still as
636
+ // pulled: the workspace's, so the apply takes it (`action-rules.ts`), and it is said here as inherited.
637
+ const inheritedWarnings: Record<string, string[]> = {}
638
+ const unchanged = unchangedSincePull(project.root)
639
+ const inheritedConflict = unchanged.refs.size ? inheritedFromPull(unchanged) : null
640
+ const semantic = inheritedConflict
641
+ ? semanticFactsOf((['semantic-types', 'shared-fields', 'object-types'] as const).flatMap((folder) => yamlIn(folder).flatMap((name) => {
642
+ const document = read(folder, name)
643
+ return document ? [{ path: `${folder}/${name}`, document }] : []
644
+ })))
645
+ : null
546
646
  for (const name of yamlIn('actions')) {
547
647
  const doc = read('actions', name)
548
648
  if (!doc || typeof doc.apiName !== 'string') continue
@@ -552,18 +652,38 @@ export function resolveBlueprint(project: LocalProject): ResolvedBlueprint {
552
652
  const touches = [fit.subject, ...changes.map((c) => c?.objectType), ...inputs.map((i) => i?.valueType?.objectType)].filter((t): t is string => typeof t === 'string')
553
653
  const offType = touches.find((t) => dropped.has(t))
554
654
  const offField = changes.map((c) => `${c?.objectType ?? fit.subject}.${c?.property}`).find((f) => droppedFields.has(f))
555
- if (offType || offField) {
556
- leftOffActions[doc.apiName] = offType ? `it acts on ${offType}, which reads the platform’s media files` : `it sets ${offField}, a calculated field the runtime leaves off`
655
+ const refusal = refused.get(`action:${doc.apiName}`)
656
+ if (offType || offField || refusal) {
657
+ const offWhy = offType && objects[offType]?.refused ? 'which the local runtime refused' : 'which reads the platform’s media files'
658
+ leftOffActions[doc.apiName] = refusal ? `the local runtime refused it: ${refusal}` : offType ? `it acts on ${offType}, ${offWhy}` : `it sets ${offField}, a calculated field the runtime leaves off`
659
+ if (refusal) refusedActions.push(doc.apiName)
557
660
  removed.push(join('actions', name))
558
661
  warnings.push(` warning: governed Action ${doc.apiName} is left off this runtime: ${leftOffActions[doc.apiName]}`)
559
662
  continue
560
663
  }
561
664
  const capability = doc.governance?.invokeCapability
562
665
  actions.push({ apiName: doc.apiName, fit, ...(typeof capability === 'string' ? { invokeCapability: capability } : {}) })
666
+ const conflicts = semantic && inheritedConflict ? actionSemanticConflicts(doc, semantic) : []
667
+ if (conflicts.length && conflicts.every((c) => inheritedConflict!(doc.apiName, c.refs))) {
668
+ inheritedWarnings[doc.apiName] = conflicts.map((c) => inheritedConflictLine(c.message))
669
+ }
670
+ }
671
+ const out: ResolvedBlueprint = {
672
+ warnings, objects, actions, leftOffActions,
673
+ ...(refusedActions.length ? { refusedActions } : {}),
674
+ ...(Object.keys(inheritedWarnings).length ? { inheritedWarnings } : {}),
675
+ ...(Object.keys(parts).length ? { parts } : {}),
676
+ ...(Object.keys(mediaSets).length ? { mediaSets, mediaTypes } : {}),
563
677
  }
564
- const out: ResolvedBlueprint = { warnings, objects, actions, leftOffActions, ...(Object.keys(mediaSets).length ? { mediaSets } : {}) }
565
678
  if (!patched.size && !removed.length) return out
566
- const copy = join('.frontera', 'local', 'resolved', 'blueprint')
679
+ // This process's own copy: a second `local dev` (Forge's watcher beside a `--once`) makes its own,
680
+ // so neither applies a folder the other is rewriting. Copies of processes gone are cleared.
681
+ const runs = join(project.root, '.frontera', 'local', 'resolved')
682
+ for (const name of existsSync(runs) ? readdirSync(runs) : []) {
683
+ const pid = /^run-(\d+)$/.exec(name)?.[1]
684
+ if (pid && Number(pid) !== process.pid && !isAlive(Number(pid))) rmSync(join(runs, name), { recursive: true, force: true })
685
+ }
686
+ const copy = join('.frontera', 'local', 'resolved', `run-${process.pid}`, 'blueprint')
567
687
  rmSync(join(project.root, copy), { recursive: true, force: true })
568
688
  cpSync(source, join(project.root, copy), { recursive: true })
569
689
  for (const [rel, text] of patched) writeFileSync(join(project.root, copy, rel), text)
@@ -571,13 +691,37 @@ export function resolveBlueprint(project: LocalProject): ResolvedBlueprint {
571
691
  return { ...out, dir: copy }
572
692
  }
573
693
 
694
+ /**
695
+ * Point each type over a Media Set's catalog at the dataset that catalog is
696
+ * on this runtime. A set's catalog is `media_<set>` unless that name was
697
+ * taken when the set was made (a sample dataset of the same name the project
698
+ * applied first): then the runtime named it otherwise, and a type left
699
+ * reading `media_<set>` would read the sample's plain text columns, which
700
+ * the runtime refuses for a media field. Edits the runtime's copy only.
701
+ */
702
+ export function pointMediaTypesAt(project: LocalProject, blueprint: ResolvedBlueprint, catalogs: Readonly<Record<string, string>>): void {
703
+ if (!blueprint.dir || !blueprint.mediaTypes) return
704
+ for (const { set, file } of Object.values(blueprint.mediaTypes)) {
705
+ const catalog = catalogs[set]
706
+ if (!catalog || catalog === `media_${set}`) continue
707
+ const at = join(project.root, blueprint.dir, file)
708
+ try {
709
+ const doc = parseYaml(readFileSync(at, 'utf8')) as Record<string, any> | null
710
+ if (!doc?.backing || typeof doc.backing !== 'object') continue
711
+ writeFileSync(at, stringifyYaml({ ...doc, backing: { ...doc.backing, dataset: catalog } }, { lineWidth: 0 }))
712
+ } catch {
713
+ // A file that does not parse is refused by the apply, and said there.
714
+ }
715
+ }
716
+ }
717
+
574
718
  /**
575
719
  * The properties each object type lets a governed Action write
576
720
  * (`backing.editable`), for the types those Actions act on. `blueprint apply`
577
721
  * leaves backings to `bind`, so `local dev` declares them itself
578
722
  * (`blueprint editable`) before an Action's write path is built.
579
723
  */
580
- export function editableSets(dir: string, subjects: ReadonlySet<string>): Map<string, string[]> {
724
+ export function editableSets(dir: string, subjects: ReadonlySet<string> | null): Map<string, string[]> {
581
725
  const out = new Map<string, string[]>()
582
726
  const at = join(dir, 'object-types')
583
727
  if (!existsSync(at)) return out
@@ -586,7 +730,7 @@ export function editableSets(dir: string, subjects: ReadonlySet<string>): Map<st
586
730
  try {
587
731
  const doc = parseYaml(readFileSync(join(at, name), 'utf8')) as { apiName?: unknown; backing?: { editable?: unknown } } | null
588
732
  const editable = doc?.backing?.editable
589
- if (typeof doc?.apiName === 'string' && subjects.has(doc.apiName) && Array.isArray(editable) && editable.length) out.set(doc.apiName, editable.map(String).sort())
733
+ if (typeof doc?.apiName === 'string' && (!subjects || subjects.has(doc.apiName)) && Array.isArray(editable) && editable.length) out.set(doc.apiName, editable.map(String).sort((a, b) => (String(a) < String(b) ? -1 : String(a) > String(b) ? 1 : 0)))
590
734
  } catch {
591
735
  // A file that does not parse is refused by the apply itself.
592
736
  }
@@ -625,14 +769,33 @@ const KNOWLEDGE_SETTINGS = ['retrieval', 'chunker', 'chunkSize', 'chunkOverlap',
625
769
  /** The files `local dev` uploads into a knowledge base: those the runtime ingests. */
626
770
  export const KNOWLEDGE_DOCUMENT = /\.(md|markdown|txt|pdf|docx|html)$/i
627
771
 
772
+ /**
773
+ * What the runtime's embedding model is compared against, when it differs:
774
+ * the linked workspace's, or, in a project with no workspace, the platform's
775
+ * default, which the default threshold is tuned for. `keyless`: the runtime
776
+ * embeds with the keyless local embeddings, whose distances no model's
777
+ * threshold fits, whatever the workspace embeds with.
778
+ */
779
+ export type EmbeddingMismatch = 'workspace' | 'platform-default' | 'keyless'
780
+
628
781
  /**
629
782
  * A knowledge base's settings as the runtime can hold them. A model it names
630
783
  * (the reranker, the extractor's, OCR's) that the runtime's catalog does not
631
784
  * have is left off here, so documents are read and searched without that
632
785
  * step instead of failing; each says so. `models` is the catalog, or null
633
786
  * when it could not be read (the settings then go as they are).
787
+ *
788
+ * When the runtime embeds with another model than the workspace, or, with
789
+ * no workspace, than the platform's default (`embeddingsDiffer`), the
790
+ * distances between a question and its passages are not the ones the
791
+ * default threshold was set for, and it finds nothing that model ranks a
792
+ * little further away: the base searches with a wider one here (only when
793
+ * the file sets none, or the default), as an adaptation. On the keyless
794
+ * local embeddings every base searches with `KEYLESS_KNOWLEDGE_THRESHOLD`,
795
+ * whatever its file sets: its threshold is for a model's distances. The
796
+ * file is never changed, and a platform never sees either threshold.
634
797
  */
635
- export function knowledgeSettings(doc: Record<string, unknown>, models: ReadonlySet<string> | null, label: string): { settings: Record<string, unknown>; warnings: string[] } {
798
+ export function knowledgeSettings(doc: Record<string, unknown>, models: ReadonlySet<string> | null, label: string, embeddingsDiffer: EmbeddingMismatch | boolean = false): { settings: Record<string, unknown>; warnings: string[] } {
636
799
  const settings: Record<string, unknown> = {}
637
800
  for (const key of KNOWLEDGE_SETTINGS) if (doc[key] !== undefined && doc[key] !== null) settings[key] = doc[key]
638
801
  // The platform keeps the threshold as a decimal, which its list sends as a string ("0.7"); the update takes numbers.
@@ -640,6 +803,15 @@ export function knowledgeSettings(doc: Record<string, unknown>, models: Readonly
640
803
  if (typeof settings[key] === 'string' && Number.isFinite(Number(settings[key]))) settings[key] = Number(settings[key])
641
804
  }
642
805
  const warnings: string[] = []
806
+ if (embeddingsDiffer === 'keyless') {
807
+ const own = settings.similarityThreshold === undefined ? '' : ` (its file’s ${String(settings.similarityThreshold)} is for a model’s embeddings, and stays the workspace’s)`
808
+ settings.similarityThreshold = KEYLESS_KNOWLEDGE_THRESHOLD
809
+ warnings.push(` warning: Knowledge base ${label} searches with threshold ${KEYLESS_KNOWLEDGE_THRESHOLD} here, set for the keyless local embeddings${own}: retrieval is approximate, by the words a passage shares with the question; a key the provider accepts for an embedding model gives the platform’s (\`frontera local status\` says which, under Embeddings)`)
810
+ } else if (embeddingsDiffer && (settings.similarityThreshold === undefined || settings.similarityThreshold === DEFAULT_KNOWLEDGE_THRESHOLD)) {
811
+ settings.similarityThreshold = WIDENED_KNOWLEDGE_THRESHOLD
812
+ const from = embeddingsDiffer === 'platform-default' ? 'the platform’s default, which its threshold is tuned for' : 'the workspace’s'
813
+ warnings.push(` warning: Knowledge base ${label} searches with threshold ${WIDENED_KNOWLEDGE_THRESHOLD} here: embeddings differ from ${from}`)
814
+ }
643
815
  const has = (model: unknown) => typeof model !== 'string' || !model.trim() || !models || models.has(model) || models.has(model.split('/').pop()!)
644
816
  const hybrid = settings.hybridConfig as Record<string, unknown> | undefined
645
817
  if (hybrid?.rerank === true && !has(hybrid.rerankerModel)) {
@@ -656,6 +828,35 @@ export function knowledgeSettings(doc: Record<string, unknown>, models: Readonly
656
828
  return { settings, warnings }
657
829
  }
658
830
 
831
+ /**
832
+ * Whether the runtime embeds with another model than the workspace (as the
833
+ * last fetch recorded the workspace's settings), the same model through
834
+ * another provider counting as the same. A project linked to no workspace
835
+ * (`local init`) is compared against the platform's default model instead:
836
+ * its knowledge files carry the default threshold, which is tuned for that
837
+ * model. `keyless` when the runtime embeds with the keyless local
838
+ * embeddings, whatever the workspace's. False when either side is not known,
839
+ * including a linked workspace not fetched yet.
840
+ */
841
+ export async function embeddingsDiffer(root: string, local: { serviceUrl: string; creds: LocalCredentials }): Promise<EmbeddingMismatch | false> {
842
+ if (!local.creds.workspaceKey) return false
843
+ const linked = readLink(root) !== null
844
+ const workspace = linked ? readSnapshot(root)?.settings?.embedding : PLATFORM_DEFAULT_EMBEDDING
845
+ try {
846
+ const here = (await syncClient(local.serviceUrl, local.creds.workspaceKey).get<OrgSettings | { data?: OrgSettings }>('/v1/sync/settings')) as OrgSettings & { data?: OrgSettings }
847
+ const settings = here?.data ?? here
848
+ if (!settings || !('embedding' in settings)) return false
849
+ if (!settings.embedding?.provider || !settings.embedding.model) return false
850
+ // Before the workspace is compared: the keyless embeddings answer as OpenAI's model, and are never it.
851
+ if (isLocalEmbeddings(settings.embedding)) return 'keyless'
852
+ if (!workspace?.provider || !workspace.model) return false
853
+ if (sameEmbedding(workspace, settings.embedding)) return false
854
+ return linked ? 'workspace' : 'platform-default'
855
+ } catch {
856
+ return false
857
+ }
858
+ }
859
+
659
860
  /** The runtime's model catalog, by slug and by provider/model id; null when it could not be read. */
660
861
  async function localModels(local: { serviceUrl: string; creds: LocalCredentials }): Promise<Set<string> | null> {
661
862
  try {
@@ -676,6 +877,48 @@ async function localModels(local: { serviceUrl: string; creds: LocalCredentials
676
877
  * search and reranking, extraction and OCR), then the folder's documents the
677
878
  * base does not hold yet are uploaded.
678
879
  */
880
+ /**
881
+ * What to say of a knowledge base with no documents in its folder: an Agent
882
+ * searching it finds nothing. A base pulled from a workspace (in
883
+ * `frontera.lock`) has its documents there, and the pull brings them; one of
884
+ * the project's own takes Markdown or text files in its folder.
885
+ */
886
+ export function emptyKnowledgeNote(root: string, folder: string, name: string): string {
887
+ const pulled = !!readLockSafe(root)?.items[`knowledge/${folder}`]
888
+ return pulled
889
+ ? `Knowledge base ${name} has no documents here, so an Agent searching it finds nothing: its documents stay in the workspace until \`frontera local sync pull --documents ${folder}\` brings them`
890
+ : `Knowledge base ${name} has no documents, so an Agent searching it finds nothing: add Markdown or text files to knowledge/${folder}/`
891
+ }
892
+
893
+ function readLockSafe(root: string): { items: Record<string, unknown> } | null {
894
+ try {
895
+ const lock = JSON.parse(readFileSync(join(root, 'frontera.lock'), 'utf8')) as { items?: Record<string, unknown> }
896
+ return lock?.items && typeof lock.items === 'object' ? { items: lock.items } : null
897
+ } catch {
898
+ return null
899
+ }
900
+ }
901
+
902
+ /**
903
+ * What a knowledge base's settings change did to its documents, in words
904
+ * (`rechunk` from the settings write): how many are being chunked again,
905
+ * why none were, and which keep their chunks (added as text). Nothing when
906
+ * the change asked for no chunking.
907
+ */
908
+ export function rechunkNotes(name: string, rechunk: unknown): string[] {
909
+ if (!rechunk || typeof rechunk !== 'object') return []
910
+ const r = rechunk as { changed?: unknown; queued?: unknown; kept?: unknown; notQueued?: unknown }
911
+ const changed = Array.isArray(r.changed) ? r.changed.filter((c): c is string => typeof c === 'string') : []
912
+ if (!changed.length) return []
913
+ const queued = typeof r.queued === 'number' ? r.queued : 0
914
+ const kept = Array.isArray(r.kept) ? r.kept.filter((k): k is string => typeof k === 'string') : []
915
+ const lines: string[] = []
916
+ if (queued) lines.push(` note: Knowledge base ${name}: ${changed.join(', ')} changed, so ${queued} document${queued === 1 ? ' is' : 's are'} being chunked again (\`frontera knowledge sources ${name}\` shows each as processing, then ready)`)
917
+ else if (typeof r.notQueued === 'string') lines.push(` warning: Knowledge base ${name}: ${changed.join(', ')} changed, but its documents keep their old chunks: ${r.notQueued}`)
918
+ if (kept.length) lines.push(` warning: Knowledge base ${name}: ${kept.join(', ')} ${kept.length === 1 ? 'was' : 'were'} added as text and keep${kept.length === 1 ? 's' : ''} ${kept.length === 1 ? 'its' : 'their'} chunks; add ${kept.length === 1 ? 'it' : 'them'} again to chunk with the new settings`)
919
+ return lines
920
+ }
921
+
679
922
  export async function applyKnowledge(
680
923
  project: LocalProject,
681
924
  target: Extract<SyncTarget, { kind: 'knowledge' }>,
@@ -703,7 +946,7 @@ export async function applyKnowledge(
703
946
  const notes: string[] = []
704
947
  const id = bases.find((b) => b.name === name)?.id
705
948
  if (local?.creds.workspaceKey && id) {
706
- const { settings, warnings } = knowledgeSettings(doc, await localModels(local), name)
949
+ const { settings, warnings } = knowledgeSettings(doc, await localModels(local), name, await embeddingsDiffer(project.root, local))
707
950
  notes.push(...warnings)
708
951
  if (Object.keys(settings).length) {
709
952
  const failed = await fetch(new URL(`/v1/workspace-knowledge/${encodeURIComponent(id)}`, local.serviceUrl), {
@@ -711,7 +954,13 @@ export async function applyKnowledge(
711
954
  headers: { authorization: `Bearer ${local.creds.workspaceKey}`, 'content-type': 'application/json' },
712
955
  body: JSON.stringify(settings),
713
956
  })
714
- .then(async (res) => (res.ok ? null : `${res.status} ${(await res.text()).slice(0, 200)}`))
957
+ .then(async (res) => {
958
+ if (!res.ok) return `${res.status} ${(await res.text()).slice(0, 200)}`
959
+ // A chunking change chunks the documents again: said, so the base is not read as unchanged.
960
+ const body = (await res.json().catch(() => null)) as { rechunk?: unknown } | null
961
+ notes.push(...rechunkNotes(name, body?.rechunk))
962
+ return null
963
+ })
715
964
  .catch((err: Error) => err.message)
716
965
  if (failed) notes.push(` warning: Knowledge base ${name} keeps the runtime’s own retrieval settings: its settings were refused (${failed})`)
717
966
  }
@@ -732,9 +981,13 @@ export async function applyKnowledge(
732
981
  // The documents, the knowledge base's folders as subfolders (`Policies/Returns/terms.pdf`).
733
982
  const { documentPath, documentPaths, folderPaths, splitDocumentPath } = await import('./local-knowledge-documents')
734
983
  const documents = documentPaths(project.root, target.name)
735
- if (!documents.length) return await done({ ok: true, output: '' })
984
+ // No documents: an Agent searching it finds nothing. Said with the command that brings them.
985
+ if (!documents.length) {
986
+ notes.push(` warning: ${emptyKnowledgeNote(project.root, target.name, name)}`)
987
+ return await done({ ok: true, output: '' })
988
+ }
736
989
  const sources = await run(['knowledge', 'sources', name, '--json', ...p])
737
- const listedSources = parseJson<Array<{ fileName?: string; folderId?: string | null }>>(sources.output) ?? []
990
+ const listedSources = parseJson<Array<{ id?: string; fileName?: string; folderId?: string | null; status?: string; updatedAt?: string }>>(sources.output) ?? []
738
991
  const key = local?.creds.workspaceKey
739
992
  const http = async (method: string, path: string, body?: FormData | Record<string, unknown>) => {
740
993
  const res = await fetch(new URL(path, local!.serviceUrl), {
@@ -752,6 +1005,9 @@ export async function applyKnowledge(
752
1005
  ? folderPaths((((await http('GET', `/v1/workspace-knowledge/${encodeURIComponent(id)}/folders`).catch(() => null))?.folders ?? []) as Array<{ id: string; parentId?: string | null; name: string }>))
753
1006
  : new Map<string, string>()
754
1007
  const have = new Set(listedSources.map((s) => documentPath(s.folderId ? folders.get(s.folderId) : null, s.fileName ?? '')))
1008
+ // A document the runtime holds pending or processing with no ingestion behind it (a rollback took its
1009
+ // reading with the database it replaced) is queued to be read again: uploading it again would add a second.
1010
+ if (key && id) notes.push(...(await requeueStuckDocuments(project, local!, name, id, listedSources)))
755
1011
  const fresh = documents.filter((f) => !have.has(f))
756
1012
  if (!fresh.length) return await done({ ok: true, output: '' })
757
1013
  const top = fresh.filter((f) => !f.includes('/'))
@@ -800,6 +1056,33 @@ export async function applyKnowledge(
800
1056
  return await done({ ok: true, output: lines.join('\n') })
801
1057
  }
802
1058
 
1059
+ /**
1060
+ * The documents of one knowledge base stuck pending or processing with no
1061
+ * ingestion behind them (`stuckSources`: unsettled since before the last
1062
+ * restore, or for half an hour), queued to be read again. Returns the lines
1063
+ * for the build.
1064
+ */
1065
+ async function requeueStuckDocuments(
1066
+ project: LocalProject,
1067
+ local: { serviceUrl: string; creds: LocalCredentials },
1068
+ name: string,
1069
+ baseId: string,
1070
+ listed: Array<{ id?: string; fileName?: string; status?: string; updatedAt?: string }>,
1071
+ ): Promise<string[]> {
1072
+ const { localIngestApi, requeueSources, shellArg, stuckLine, stuckSources } = await import('./local-knowledge-ingestion')
1073
+ const { readBaseline } = await import('./local-db-snapshot')
1074
+ const api = localIngestApi(local.serviceUrl, local.creds)
1075
+ if (!api) return []
1076
+ const sources = listed.filter((s) => s.id).map((s) => ({ base: name, baseId, id: s.id!, fileName: s.fileName ?? s.id!, status: String(s.status ?? ''), ...(s.updatedAt ? { updatedAt: s.updatedAt } : {}) }))
1077
+ const stuck = stuckSources(sources, { restoredAt: readBaseline(project, local.creds.organizationId)?.restoredAt })
1078
+ if (!stuck.length) return []
1079
+ const { requeued, refused } = await requeueSources(api, stuck)
1080
+ return [
1081
+ ...(requeued.length ? [` note: Knowledge base ${name}: queued ${requeued.map((s) => s.fileName).join(', ')} to be read again: ${requeued.length === 1 ? 'it was' : 'they were'} ${requeued.length === 1 ? requeued[0]!.status : 'pending or processing'} with no ingestion behind ${requeued.length === 1 ? 'it' : 'them'} (\`frontera knowledge sources ${shellArg(name)}\` shows each as pending, then ready)`] : []),
1082
+ ...refused.map((r) => ` warning: ${stuckLine(r.source)} (re-queuing it was refused: ${r.reason})`),
1083
+ ]
1084
+ }
1085
+
803
1086
  /**
804
1087
  * A knowledge base's quality checks on the runtime (`local-quality-checks.ts`),
805
1088
  * their documents by this runtime's ids. Returns the lines for the build.
@@ -1024,21 +1307,35 @@ async function setToolPolicy(local: { serviceUrl: string; creds: LocalCredential
1024
1307
  }
1025
1308
 
1026
1309
  /**
1027
- * Promote a workflow's latest draft to live on the local runtime. Returns the
1028
- * version promoted, or null when there was none or it was refused.
1310
+ * Promote a workflow's version to live on the local runtime: `version` (the
1311
+ * one `local dev` pushed its file as) when given, else the newest draft, but
1312
+ * only one newer than the live version. After a promotion the newest draft
1313
+ * can be an older push, and promoting that would put stale code live. Returns
1314
+ * the version promoted, or null when there was none, it was live already, or
1315
+ * it was refused.
1029
1316
  */
1030
- export async function promoteWorkflow(serviceUrl: string, creds: LocalCredentials, slug: string): Promise<number | null> {
1317
+ export async function promoteWorkflow(serviceUrl: string, creds: LocalCredentials, slug: string, version?: number, opts: { onlyIfLive?: boolean } = {}): Promise<number | null> {
1031
1318
  if (!creds.workspaceKey) return null
1032
1319
  const call = (path: string, method: string) =>
1033
1320
  fetch(new URL(path, serviceUrl), { method, headers: { authorization: `Bearer ${creds.workspaceKey}`, 'content-type': 'application/json' }, body: method === 'POST' ? '{}' : undefined })
1034
1321
  const res = await call(`/v1/workflows/${encodeURIComponent(slug)}`, 'GET')
1035
1322
  if (!res.ok) return null
1036
- const n = ((await res.json()) as { data?: { latestDraftNumber?: number | null } }).data?.latestDraftNumber
1323
+ const found = ((await res.json()) as { data?: { latestDraftNumber?: number | null; liveVersion?: number | null } }).data
1324
+ // `onlyIfLive`: a workflow with no live version stays a draft (its triggers stay unarmed).
1325
+ if (opts.onlyIfLive && !found?.liveVersion) return null
1326
+ const n = promotionTarget(found?.latestDraftNumber ?? null, found?.liveVersion ?? null, version)
1037
1327
  if (!n) return null
1038
1328
  const promoted = await call(`/v1/workflows/${encodeURIComponent(slug)}/versions/${n}/promote`, 'POST')
1039
1329
  return promoted.ok ? n : null
1040
1330
  }
1041
1331
 
1332
+ /** Which version a promotion makes live: the pushed one, else a draft newer than the live one; null when nothing should change. */
1333
+ export function promotionTarget(latestDraft: number | null, live: number | null, pushed?: number): number | null {
1334
+ if (pushed) return pushed === live ? null : pushed
1335
+ if (!latestDraft) return null
1336
+ return live && latestDraft < live ? null : latestDraft
1337
+ }
1338
+
1042
1339
  /** The project's object type files by API name, parsed; unreadable ones are left out. */
1043
1340
  function projectObjectTypes(root: string): Map<string, Record<string, unknown>> {
1044
1341
  const dir = join(root, 'blueprint', 'object-types')
@@ -1086,15 +1383,25 @@ export async function applyBindings(project: LocalProject, target: Extract<SyncT
1086
1383
  // An Agent runs only a workflow's live version, and local dev pushes drafts: on this
1087
1384
  // one-person runtime, a workflow an Agent may start is promoted so the binding holds.
1088
1385
  const promote = async (slug: string): Promise<boolean> => {
1089
- const n = await promoteWorkflow(serviceUrl, creds, slug)
1386
+ // The version the workflow's file was pushed as, not whatever draft is newest.
1387
+ const n = await promoteWorkflow(serviceUrl, creds, slug, pushedWorkflowVersion(project, creds.organizationId, slug))
1090
1388
  if (n) notes.push(` warning: promoted workflow ${slug} v${n} so ${target.slug} can start it`)
1091
1389
  return n !== null
1092
1390
  }
1093
1391
  const failures: string[] = []
1094
1392
  const settings = (row: Record<string, unknown>, keys: string[]) => Object.fromEntries(keys.filter((k) => row[k] !== undefined).map((k) => [k, row[k]]))
1393
+ // The last build: a Function of the project's that failed to build here, and a type the runtime refused.
1394
+ const build = loadBuild(project, creds.organizationId)
1095
1395
  for (const row of doc.functions ?? []) {
1096
1396
  const ref = typeof row.function === 'string' ? parseRef(row.function) : null
1097
1397
  if (!ref || ref.kind !== 'function') continue
1398
+ // A Function of the project that does not build here: the Agent goes without it, and gets it once it builds.
1399
+ const fn = build[`function:${ref.name}/`] ?? build[`function:${ref.name}.ts`]
1400
+ if (fn?.status === 'failed') {
1401
+ const why = oneLine(fn.error ?? 'it failed to build')
1402
+ notes.push(` warning: ${target.slug} goes without the Function ${ref.name} here until it builds: ${why.charAt(0).toLowerCase()}${why.slice(1)}`)
1403
+ continue
1404
+ }
1098
1405
  const failed = await put(`/v1/functions/${encodeURIComponent(ref.name)}/bindings/${encodeURIComponent(target.slug)}`, settings(row, ['enabled', 'requiresApproval', 'waitSeconds']))
1099
1406
  if (failed) failures.push(` ${row.function}: ${failed}`)
1100
1407
  }
@@ -1113,7 +1420,7 @@ export async function applyBindings(project: LocalProject, target: Extract<SyncT
1113
1420
  const unread = !Array.isArray(doc.blueprint) && (doc as { blueprintUnread?: unknown }).blueprintUnread === true
1114
1421
  if (Array.isArray(doc.blueprint) || unread) {
1115
1422
  const types = projectObjectTypes(project.root)
1116
- const keptHere = [...types].filter(([, type]) => !objectTypeFit(type, () => true).leftOff).map(([apiName]) => apiName).sort()
1423
+ const keptHere = [...types].filter(([, type]) => !objectTypeFit(type, () => true).leftOff).map(([apiName]) => apiName).sort((a, b) => (String(a) < String(b) ? -1 : String(a) > String(b) ? 1 : 0))
1117
1424
  const granted = await applyAgentGrants({
1118
1425
  slug: target.slug,
1119
1426
  grants: unread ? keptHere.map((apiName) => ({ objectType: `objectType/${apiName}`, permissions: ['read'] })) : (doc.blueprint as AgentGrant[]),
@@ -1121,9 +1428,13 @@ export async function applyBindings(project: LocalProject, target: Extract<SyncT
1121
1428
  creds,
1122
1429
  leftOff: (apiName) => {
1123
1430
  const type = types.get(apiName)
1124
- return type && objectTypeFit(type, () => true).leftOff ? 'it reads the platform’s media files' : null
1431
+ if (type && objectTypeFit(type, () => true).leftOff) return 'it reads the platform’s media files'
1432
+ const refused = build.blueprint?.objects?.[apiName]?.refused
1433
+ return refused ? `the local runtime refused it: ${refused}` : null
1125
1434
  },
1126
1435
  inProject: (apiName) => types.has(apiName),
1436
+ // Assumed as read because the pull couldn't read them: what the runtime already gives is kept.
1437
+ keepHeld: unread,
1127
1438
  })
1128
1439
  failures.push(...granted.failures)
1129
1440
  notes.push(...granted.notes)