@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
@@ -3,8 +3,9 @@ import { mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync
3
3
  import { tmpdir } from 'node:os'
4
4
  import { join, posix, relative } from 'node:path'
5
5
 
6
- import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'
6
+ import { Document as YamlDocument, isSeq, parse as parseYaml, stringify as stringifyYaml, type YAMLMap, type YAMLSeq } from 'yaml'
7
7
 
8
+ import { semanticFactsOf } from '../blueprint/action-semantic-fit'
8
9
  import { toFiles } from '../blueprint/projection'
9
10
  import { serialize } from '../blueprint/tree'
10
11
  import { assetFilename, toSkillMd, type SkillDoc } from '../commands/skill/bundle-commands'
@@ -20,11 +21,15 @@ import { readChatApps, type ChatAppRow } from './local-chat-apps'
20
21
  import { readApps, type AppRow as CustomAppRow } from './local-apps'
21
22
  import { readBrand, type BrandRow } from './local-brand'
22
23
  import { readLibrary, type LibraryRow } from './local-library'
24
+ import { fromOutsidePath, outsidePath } from '../function-outside-imports'
23
25
  import { isSharedFunctionFile } from './local-function-shared'
26
+ import { hardCodedObjectTypeIds, platformObjectTypeIds } from './local-function-ids'
27
+ import { missingImportNote, missingImportWarning, recoverMissingImports, type MissingImports } from './local-function-recovery'
24
28
  import { attachDefinitions, type ObjectDefinitionRow } from './local-objects'
25
29
  import { checksFromFixtures, fixtureRows, qualityChecksPath, qualityChecksText, sourcePaths, sourceRows } from './local-quality-checks'
26
30
  import { readableName } from './local-sync-names'
27
- import { governedActionIds, invert, itemRef, referencesIn, toNames, type Directory, type RefKind } from './local-sync-refs'
31
+ import { WORKSPACE_GRANTS_FILE, WORKSPACE_GRANTS_NAME } from './local-workspace-grants'
32
+ import { governedActionIds, invert, itemRef, referencesIn, refName as refNameOf, toNames, type Directory, type RefKind } from './local-sync-refs'
28
33
 
29
34
  /**
30
35
  * Reading a workspace as the files a project holds. Each kind lists what the
@@ -57,9 +62,9 @@ function one<T>(body: unknown): T {
57
62
  return v as T
58
63
  }
59
64
 
60
- /** A resource's name as a reference can hold it: knowledge bases are named in words. */
65
+ /** A resource's name as a reference can hold it: knowledge bases are named in words (`local-sync-refs.ts`). */
61
66
  export function refName(name: string): string {
62
- return /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name) ? name : slugify(name) || 'unnamed'
67
+ return refNameOf(name)
63
68
  }
64
69
 
65
70
  async function quietly<T>(warnings: string[], what: string, read: () => Promise<T>, empty: T): Promise<T> {
@@ -370,7 +375,7 @@ function finish(it: RemoteItem, directory: Directory, docs: Array<{ path: string
370
375
  }
371
376
  it.files[path] = format === 'json' ? json(named) : stringifyYaml(named, { lineWidth: 0 })
372
377
  }
373
- it.requires = [...requires].sort()
378
+ it.requires = [...requires].sort((a, b) => (String(a) < String(b) ? -1 : String(a) > String(b) ? 1 : 0))
374
379
  return it
375
380
  }
376
381
 
@@ -382,9 +387,11 @@ export interface AgentBindings {
382
387
  /**
383
388
  * Its Context and data: the object types it may read through its Blueprint
384
389
  * tools (`objectType/Incident`), with the permissions granted. Absent when
385
- * it has none, or they could not be read (no organization key).
390
+ * it has none, or they could not be read (no organization key). An entry
391
+ * without `permissions` was read by name only: `local dev` and a change
392
+ * request then keep what the grant holds (`act`, `read_sensitive`).
386
393
  */
387
- blueprint?: Array<{ objectType: string; permissions: string[] }>
394
+ blueprint?: Array<{ objectType: string; permissions?: string[] }>
388
395
  /**
389
396
  * Its Context and data could not be read on the platform with the project's
390
397
  * keys. `local dev` then gives it read access to the object types the
@@ -401,7 +408,7 @@ export type AgentContext = { grants: Grants; note?: string } | { unread: string
401
408
 
402
409
  const asGrants = (granted: Array<{ objectTypeApiName?: string; permissions?: string[] }>): Grants =>
403
410
  granted
404
- .flatMap((g) => (typeof g.objectTypeApiName === 'string' ? [{ objectType: `objectType/${g.objectTypeApiName}`, permissions: [...(g.permissions?.length ? g.permissions : ['read'])].sort() }] : []))
411
+ .flatMap((g) => (typeof g.objectTypeApiName === 'string' ? [{ objectType: `objectType/${g.objectTypeApiName}`, permissions: [...(g.permissions?.length ? g.permissions : ['read'])].sort((a, b) => (String(a) < String(b) ? -1 : String(a) > String(b) ? 1 : 0)) }] : []))
405
412
  .sort((a, b) => a.objectType.localeCompare(b.objectType))
406
413
 
407
414
  const STEWARD_FIX = 'An organization key made by a Blueprint Steward with organization update reads it, and so does the workspace key on a newer platform'
@@ -439,8 +446,10 @@ export async function readAgentContext(source: SyncSource, row: AgentRow): Promi
439
446
  // An empty answer may only mean the key can't see the Agent there, so it is not taken for "none".
440
447
  if (listed.length) {
441
448
  return {
442
- grants: listed.map((n) => ({ objectType: `objectType/${n}`, permissions: ['read'] })).sort((a, b) => a.objectType.localeCompare(b.objectType)),
443
- note: 'Its Context and data was read as the object types it may use: this platform doesn’t give their permissions to this key, so read is taken for each',
449
+ // No `permissions`: the file doesn't state what it didn't read, so applying it keeps what each grant
450
+ // holds. Written as `["read"]`, a change request or local dev would take a held `act` away.
451
+ grants: listed.map((n) => ({ objectType: `objectType/${n}` })).sort((a, b) => a.objectType.localeCompare(b.objectType)),
452
+ note: 'Its Context and data was read as the object types it may use: this platform doesn’t give their permissions to this key, so each is written without permissions, and applying the file keeps what each grant holds (read, and act or read_sensitive where it has them)',
444
453
  }
445
454
  }
446
455
  return { unread: refused ?? `this platform gives it only to an organization key. ${STEWARD_FIX}` }
@@ -473,6 +482,81 @@ async function readBindings(source: SyncSource, lists: Lists, warnings: string[]
473
482
  return out
474
483
  }
475
484
 
485
+ const NO_SETTINGS = ': it runs on the defaults there, and the same defaults here; `frontera local schema operating` says what the file can set'
486
+
487
+ /** Settings an Agent has: an object with at least one key. */
488
+ function settingsIn(value: unknown): Record<string, unknown> | null {
489
+ return value && typeof value === 'object' && !Array.isArray(value) && Object.keys(value).length ? (value as Record<string, unknown>) : null
490
+ }
491
+
492
+ /**
493
+ * An Agent's behavior settings from their own route (`/v1/config/agents/:id/operating`):
494
+ * the live ones, or the draft's when the draft is what was read. The last
495
+ * place a pull looks, after the snapshot and the Agent's own record (both of
496
+ * which a released platform gives a workspace key, scrubbed). A note in every
497
+ * case: where they came from, that the Agent has none (so it is pulled
498
+ * without operating.json, and `local dev` uses the runtime's defaults), or why
499
+ * they could not be read. A released platform keeps the route to a signed-in
500
+ * person, so its refusal (or a platform without it) is taken for what the
501
+ * snapshot said: none.
502
+ */
503
+ export async function readOperatingConfig(source: SyncSource, agentId: string, draft: boolean): Promise<{ config: Record<string, unknown>; note: string } | { note: string }> {
504
+ try {
505
+ const view = one<{ configured?: boolean; live?: unknown; draft?: unknown }>(await source.workspace.get(`/v1/config/agents/${encodeURIComponent(agentId)}/operating`))
506
+ const chosen = settingsIn(draft ? view?.draft ?? view?.live : view?.live)
507
+ if (chosen) return { config: chosen, note: `Its behavior settings were read from the platform${draft && view?.draft ? '’s draft' : ''} into operating.json` }
508
+ return { note: `Has no behavior settings in the workspace (operating.json)${NO_SETTINGS}` }
509
+ } catch (err) {
510
+ const code = err instanceof CliError ? err.code : null
511
+ if (code === 'UNAUTHORIZED' || code === 'FORBIDDEN' || code === 'NOT_FOUND') return { note: `Has no behavior settings in its snapshot on the platform (operating.json)${NO_SETTINGS}` }
512
+ return { note: `Its behavior settings couldn’t be read on the platform (${(err as Error).message.slice(0, 160)}), so no operating.json was pulled: if it has some there, they aren’t here` }
513
+ }
514
+ }
515
+
516
+ /**
517
+ * What a pull says of behavior settings without `execution.toolScopes` (as a
518
+ * released platform gives them): they keep the meaning tool ids had before
519
+ * Blueprint Actions were their own choice, here as there, and `local dev`
520
+ * warns about it. Null when the marker is there.
521
+ */
522
+ export function legacyToolScopesNote(config: Record<string, unknown>): string | null {
523
+ const execution = settingsIn(config.execution) ?? {}
524
+ if (execution.toolScopes === 2) return null
525
+ return 'Its behavior settings have no `execution.toolScopes`, as the workspace keeps them, so they keep the meaning tool ids had before Blueprint Actions were their own choice (`tool:blueprint` keeps the Action tools, and a service Agent has them outside procedures), here as there; `frontera local dev` warns about it until "toolScopes": 2 is added to "execution"'
526
+ }
527
+
528
+ /**
529
+ * An Agent's behavior settings, from the first place that has them:
530
+ * 1. the snapshot that was read (`draft-v2` for a draft, else `snapshot/live`), its `config.operatingConfig`;
531
+ * 2. for a live read, the Agent's record (`/v1/config/agents/:id`), then the listing's row;
532
+ * 3. for a draft read with none, none: the draft is the whole of them;
533
+ * 4. only then their own route (`readOperatingConfig`).
534
+ */
535
+ export async function agentOperatingConfig(
536
+ source: SyncSource,
537
+ agent: { id: string; operatingConfig?: unknown },
538
+ read: { snapshot: Record<string, unknown>; fromDraft: boolean; detail?: { operatingConfig?: unknown } | null },
539
+ ): Promise<{ config: Record<string, unknown> | null; notes: string[] }> {
540
+ const fromSnapshot = settingsIn((read.snapshot.config as Record<string, unknown> | undefined)?.operatingConfig)
541
+ const found = fromSnapshot ?? (read.fromDraft ? null : settingsIn(read.detail?.operatingConfig) ?? settingsIn(agent.operatingConfig))
542
+ const notes: string[] = []
543
+ let config = found
544
+ if (!config) {
545
+ if (read.fromDraft) notes.push(`Has no behavior settings in its draft (operating.json)${NO_SETTINGS}`)
546
+ else {
547
+ const route = await readOperatingConfig(source, agent.id, false)
548
+ if ('config' in route) config = route.config
549
+ notes.push(route.note)
550
+ }
551
+ }
552
+ const legacy = config ? legacyToolScopesNote(config) : null
553
+ if (legacy) notes.push(legacy)
554
+ return { config, notes }
555
+ }
556
+
557
+ /** An Agent's record (`/v1/config/agents/:id`): its published version, and its behavior settings, scrubbed. */
558
+ interface AgentDetail { currentVersion?: { versionNumber?: number } | null; operatingConfig?: unknown }
559
+
476
560
  async function readAgents(source: SyncSource, lists: Lists, directory: Directory, opts: ReadOptions, warnings: string[], tools: () => Promise<PluginTools>): Promise<RemoteItem[]> {
477
561
  const out: RemoteItem[] = []
478
562
  const agents = lists.agents.filter((row) => wanted(opts, `agent/${row.agentId}`))
@@ -483,30 +567,36 @@ async function readAgents(source: SyncSource, lists: Lists, directory: Directory
483
567
  const wantDraft = opts.drafts?.has(ref) || !row.currentVersionId
484
568
  const it = item('agent', row.agentId, row.name, {}, { id: row.id })
485
569
  let snapshot: Record<string, unknown> | null = null
570
+ let detail: AgentDetail | null = null
571
+ let fromDraft = false
486
572
  try {
487
573
  if (wantDraft) {
488
574
  const draft = one<{ snapshot?: Record<string, unknown>; revision?: number } | null>(await source.workspace.get(`/v1/config/agents/${row.id}/draft-v2`))
489
575
  snapshot = draft?.snapshot ?? null
576
+ fromDraft = !!snapshot
490
577
  if (!row.currentVersionId) it.notes.push('Not published on the platform: its draft was read')
491
578
  else it.notes.push('Read from its draft, as asked')
492
579
  if (draft?.revision !== undefined) it.version = `draft ${draft.revision}`
493
580
  }
494
581
  if (!snapshot) {
495
582
  snapshot = one<Record<string, unknown>>(await source.workspace.get(`/v1/config/agents/${row.id}/snapshot/live`))
496
- const detail = one<{ currentVersion?: { versionNumber?: number } | null }>(await source.workspace.get(`/v1/config/agents/${row.id}`))
583
+ detail = one<AgentDetail | null>(await source.workspace.get(`/v1/config/agents/${row.id}`))
497
584
  if (detail?.currentVersion?.versionNumber) it.version = `v${detail.currentVersion.versionNumber}`
498
585
  }
499
586
  } catch (err) {
500
587
  warnings.push(`Agent ${row.agentId}: ${(err as Error).message}`)
501
588
  continue
502
589
  }
503
- const operating = ((snapshot.config as Record<string, unknown> | undefined)?.operatingConfig ?? (wantDraft ? null : row.operatingConfig)) as Record<string, unknown> | null
590
+ // Where its behavior settings are (the snapshot, the Agent's record, their own route), or that it has none, said.
591
+ const behavior = await agentOperatingConfig(source, row, { snapshot, fromDraft, detail })
592
+ const operating = behavior.config
593
+ it.notes.push(...behavior.notes)
504
594
  const toolSettings = Array.isArray(snapshot.appCapabilities) && snapshot.appCapabilities.length ? { apps: lists.apps, tools: await tools() } : undefined
505
595
  const agentDoc = projectAgent(snapshot, it.notes, toolSettings)
506
596
  // Its object tools carry the definitions they work on, named by slug below.
507
597
  it.notes.push(...attachDefinitions(agentDoc, lists.objectDefinitions ?? null))
508
598
  const docs: Array<{ path: string; doc: unknown; format: 'json' }> = [{ path: `agents/${row.agentId}/agent.json`, doc: agentDoc, format: 'json' }]
509
- if (operating && typeof operating === 'object' && Object.keys(operating).length) docs.push({ path: `agents/${row.agentId}/operating.json`, doc: operating, format: 'json' })
599
+ if (operating) docs.push({ path: `agents/${row.agentId}/operating.json`, doc: operating, format: 'json' })
510
600
  const bound: AgentBindings = { ...(bindings.get(row.agentId) ?? { functions: [], workflows: [] }) }
511
601
  // Context and data comes as the Agent's dependency: each object type it names is pulled with it.
512
602
  const context = await readAgentContext(source, row)
@@ -583,15 +673,129 @@ function unpack(bytes: Uint8Array): Map<string, string> {
583
673
  }
584
674
  }
585
675
 
586
- /** The file and the relative files it imports, transitively, within the archive. */
587
- function withImports(files: Map<string, string>, entry: string): string[] {
676
+ /** The project files a Function's folder keeps beside its code. */
677
+ const FOLDER_PROJECT_FILES = ['package.json', 'tsconfig.json', 'bunfig.toml', 'README.md']
678
+
679
+ /** The SDK's own copy inside a Function project (`function init` vendors it there); the solution keeps one in vendor/. */
680
+ const FOLDER_SDK = /^(src\/)?frontera\//
681
+
682
+ /**
683
+ * The entry in a folder Function's source archive: the file whose
684
+ * `automation({ name })` is this Function (one archive can hold every
685
+ * Function of a project), else `<slug>.ts`, `src/<slug>.ts`, `index.ts`,
686
+ * `src/index.ts`; the rule `functionEntry` applies to a project folder.
687
+ */
688
+ export function archiveEntry(files: ReadonlyMap<string, string>, slug: string): string | null {
689
+ const code = [...files.keys()].filter((p) => p.endsWith('.ts') && !p.endsWith('.test.ts') && !p.endsWith('.d.ts') && !FOLDER_SDK.test(p)).sort()
690
+ const escaped = slug.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
691
+ const declares = new RegExp(`['"]?name['"]?\\s*:\\s*['"\`]${escaped}['"\`]`)
692
+ const named = code.find((p) => /automation\s*\(/.test(files.get(p)!) && declares.test(files.get(p)!))
693
+ return named ?? [`${slug}.ts`, `src/${slug}.ts`, 'index.ts', 'src/index.ts'].find((p) => files.has(p)) ?? null
694
+ }
695
+
696
+ /**
697
+ * What a folder Function's pull writes, relative to `functions/<slug>/`: its
698
+ * entry and the files it imports, its test, and the folder's project files,
699
+ * with the tsconfig's SDK paths pointed at the solution's `vendor/frontera/`.
700
+ * Without an entry, every file but the SDK copy (and `entry` is null).
701
+ */
702
+ export function folderFunctionFiles(
703
+ files: ReadonlyMap<string, string>,
704
+ slug: string,
705
+ ): { entry: string | null; files: Record<string, string>; outside: Record<string, string>; missing: string[] } {
706
+ const entry = archiveEntry(files, slug)
707
+ const out: Record<string, string> = {}
708
+ // What it imports from outside its folder, by its path from the folder (`../shared/x.ts`): kept in
709
+ // the archive under `frontera-imports/` by a current deploy; `missing` when the archive lacks it.
710
+ const outside: Record<string, string> = {}
711
+ const missing: string[] = []
712
+ if (!entry) {
713
+ for (const [p, content] of files) if (!p.startsWith('.') && !FOLDER_SDK.test(p) && !fromOutsidePath(p)) out[p] = content
714
+ } else {
715
+ const code = withImports(files as Map<string, string>, entry, missing).filter((p) => !FOLDER_SDK.test(p))
716
+ const test = entry.replace(/\.ts$/, '.test.ts')
717
+ for (const p of [...code, ...(files.has(test) ? [test] : []), ...FOLDER_PROJECT_FILES.filter((f) => files.has(f))]) {
718
+ const beyond = fromOutsidePath(p)
719
+ if (beyond) outside[beyond] = files.get(p)!
720
+ else out[p] = files.get(p)!
721
+ }
722
+ }
723
+ if (out['tsconfig.json']) out['tsconfig.json'] = vendorPaths(out['tsconfig.json'])
724
+ Object.assign(out, sdkShims(out))
725
+ return { entry, files: out, outside, missing: [...new Set(missing)].sort() }
726
+ }
727
+
728
+ /** The first line of a file a pull writes to point a folder's own SDK path at the solution's copy. */
729
+ export const SDK_SHIM_HEADER = '// Written by `frontera local sync pull`: this Function imports its folder’s own copy of the SDK, which the project keeps once, in vendor/frontera/.'
730
+
731
+ /**
732
+ * Files that point a Function folder's own SDK copy (`./frontera/automation/testing`,
733
+ * which `function init` vendored beside its code) at the solution's `vendor/frontera/`.
734
+ * A pull leaves the folder's copy out (the project keeps one), and a relative
735
+ * import cannot be mapped by tsconfig `paths`; so each such import gets a file
736
+ * that re-exports the solution's module, and the pulled code stays as it was.
737
+ * Keyed by path in the folder (`src/frontera/automation/testing.ts`).
738
+ */
739
+ export function sdkShims(files: Readonly<Record<string, string>>): Record<string, string> {
740
+ const shims: Record<string, string> = {}
741
+ for (const [path, text] of Object.entries(files)) {
742
+ if (!/\.(ts|tsx|mts)$/.test(path)) continue
743
+ for (const m of text.matchAll(/(?:from|import)\s*\(?\s*['"](\.{1,2}\/[^'"]+)['"]/g)) {
744
+ const target = posix.normalize(posix.join(posix.dirname(path), m[1]!)).replace(/\.(ts|js)$/, '')
745
+ if (!FOLDER_SDK.test(target) || target.startsWith('../')) continue
746
+ const shim = `${target}.ts`
747
+ if (files[shim] || files[`${target}/index.ts`] || shims[shim]) continue
748
+ // From functions/<slug>/<shim's folder> up to the project root, then into vendor/.
749
+ const up = posix.relative(posix.join('functions', '_', posix.dirname(shim)), '.')
750
+ const module = target.replace(/^(src\/)?/, '')
751
+ shims[shim] = `${SDK_SHIM_HEADER}\nexport * from '${up}/vendor/${module}'\n`
752
+ }
753
+ }
754
+ return shims
755
+ }
756
+
757
+ /** A Function folder's tsconfig with its `@frontera-sdk/*` paths at the solution's vendor/frontera/ (two folders up). */
758
+ export function vendorPaths(tsconfig: string): string {
759
+ let doc: { compilerOptions?: { baseUrl?: string; paths?: Record<string, string[]> } }
760
+ try {
761
+ doc = JSON.parse(tsconfig)
762
+ } catch {
763
+ return tsconfig
764
+ }
765
+ const paths = doc.compilerOptions?.paths
766
+ if (!paths) return tsconfig
767
+ const base = doc.compilerOptions!.baseUrl ?? '.'
768
+ const up = posix.relative(posix.normalize(base), '../..') || '.'
769
+ for (const [alias, targets] of Object.entries(paths)) {
770
+ if (!alias.startsWith('@frontera-sdk/')) continue
771
+ paths[alias] = targets.map((t) => t.replace(/^(\.\/)?(src\/)?frontera\//, `${up}/vendor/frontera/`))
772
+ }
773
+ return `${JSON.stringify(doc, null, 2)}\n`
774
+ }
775
+
776
+ /**
777
+ * The file and the relative files it imports, transitively, within the
778
+ * archive. An import that leaves the archive's folder (`../shared/x`) is
779
+ * found under `frontera-imports/` (`outsidePath`), where a current deploy
780
+ * keeps it; one the archive lacks is added to `missing`, by its path from
781
+ * the folder.
782
+ */
783
+ function withImports(files: Map<string, string>, entry: string, missing: string[] = []): string[] {
588
784
  const seen = new Set<string>()
589
785
  const visit = (path: string) => {
590
786
  if (seen.has(path) || !files.has(path)) return
591
787
  seen.add(path)
788
+ // A file kept from outside the folder imports by its own place there.
789
+ const here = fromOutsidePath(path) ?? path
592
790
  for (const m of files.get(path)!.matchAll(/from\s+['"](\.{1,2}\/[^'"]+)['"]/g)) {
593
- const base = posix.normalize(posix.join(posix.dirname(path), m[1]!))
594
- for (const candidate of [base, `${base}.ts`, `${base}/index.ts`]) if (files.has(candidate)) visit(candidate)
791
+ const base = posix.normalize(posix.join(posix.dirname(here), m[1]!))
792
+ const candidates = [base, `${base}.ts`, `${base}/index.ts`, `${base}.js`, `${base}/index.js`].map((c) => (c.startsWith('../') ? outsidePath(c) : c))
793
+ const found = candidates.find((c) => files.has(c))
794
+ if (found) {
795
+ visit(found)
796
+ // A JavaScript file's types beside it (a module a pull recovered from a deployed bundle).
797
+ if (found.endsWith('.js')) visit(`${found.slice(0, -'.js'.length)}.d.ts`)
798
+ } else if (base.startsWith('../')) missing.push(base)
595
799
  }
596
800
  }
597
801
  visit(entry)
@@ -622,6 +826,7 @@ async function readEditorDraft(source: SyncSource, row: FunctionRow, it: RemoteI
622
826
 
623
827
  async function readFunctions(source: SyncSource, lists: Lists, opts: ReadOptions, warnings: string[]): Promise<RemoteItem[]> {
624
828
  const out: RemoteItem[] = []
829
+ const missingImports: MissingImports[] = []
625
830
  for (const row of lists.functions) {
626
831
  const ref = `function/${row.slug}`
627
832
  if (!wanted(opts, ref)) continue
@@ -661,7 +866,7 @@ async function readFunctions(source: SyncSource, lists: Lists, opts: ReadOptions
661
866
  const keep = imported.filter((p) => p.startsWith('functions/'))
662
867
  const test = `functions/${row.slug}.test.ts`
663
868
  if (files.has(test)) keep.push(test)
664
- const beyond = imported.filter((p) => !p.startsWith('functions/') && !p.startsWith('vendor/')).sort()
869
+ const beyond = imported.filter((p) => !p.startsWith('functions/') && !p.startsWith('vendor/')).sort((a, b) => (String(a) < String(b) ? -1 : String(a) > String(b) ? 1 : 0))
665
870
  const shared = beyond.filter(isSharedFunctionFile)
666
871
  const outside = beyond.filter((p) => !isSharedFunctionFile(p))
667
872
  for (const p of [...keep, ...shared]) it.files[p] = files.get(p)!
@@ -672,15 +877,50 @@ async function readFunctions(source: SyncSource, lists: Lists, opts: ReadOptions
672
877
  warnings.push(`Function ${row.slug}: imports ${list(outside)}, which a pull does not write into a folder of the project’s own items`)
673
878
  }
674
879
  } else {
675
- // A Function deployed from its own folder: the folder, under functions/<slug>/.
676
- for (const [p, content] of files) if (!p.startsWith('.')) it.files[`functions/${row.slug}/${p}`] = content
880
+ // A Function deployed from its own folder: its entry, what that imports, its test and the
881
+ // folder's project files, under functions/<slug>/; never its siblings or the SDK copy.
882
+ const folder = folderFunctionFiles(files, row.slug)
883
+ for (const [p, content] of Object.entries(folder.files)) it.files[`functions/${row.slug}/${p}`] = content
677
884
  if (!Object.keys(it.files).length) continue
678
885
  it.notes.push('Deployed from its own folder; pulled as functions/' + row.slug + '/')
886
+ // What it imports from beside its folder, written where the import finds it: a shared file of the
887
+ // solution (`functions/shared/…`, `lib/…`), never inside another Function's folder or above the project.
888
+ const folders = new Set(lists.functions.map((f) => f.slug))
889
+ const beside = (fromFolder: string) => {
890
+ const path = posix.normalize(posix.join('functions', row.slug, fromFolder))
891
+ const parts = path.split('/')
892
+ if (path.startsWith('../') || parts.some((p) => p.startsWith('.'))) return null
893
+ if (parts[0] === 'functions') return parts.length >= 3 && !folders.has(parts[1]!) ? path : null
894
+ return isSharedFunctionFile(path) ? path : null
895
+ }
896
+ const brought: string[] = []
897
+ for (const [rel, content] of Object.entries(folder.outside)) {
898
+ const path = beside(rel)
899
+ if (!path) continue
900
+ it.files[path] = content
901
+ brought.push(path)
902
+ }
903
+ const list = (paths: string[]) => (paths.length > 3 ? `${paths.slice(0, 3).join(', ')} and ${paths.length - 3} more` : paths.length > 1 ? `${paths.slice(0, -1).join(', ')} and ${paths[paths.length - 1]}` : paths.join(''))
904
+ if (brought.length) it.notes.push(`Brings ${list(brought.sort())}, which it imports from beside its folder, so it builds as in the workspace`)
905
+ // What its source lacks: from another Function's source, else cut back out of its deployed
906
+ // bundle once every Function is read (`recoverMissingImports`), else said.
907
+ if (folder.missing.length && folder.entry) {
908
+ missingImports.push({ it, slug: row.slug, version: row.liveVersion, files, entry: folder.entry, missing: folder.missing, writable: (path) => beside(posix.relative(posix.join('functions', row.slug), `${path}.js`)) === `${path}.js` })
909
+ } else if (folder.missing.length) {
910
+ const lacking = folder.missing.map((m) => posix.normalize(posix.join('functions', row.slug, m)))
911
+ it.notes.push(missingImportNote(lacking))
912
+ warnings.push(missingImportWarning(row.slug, lacking))
913
+ }
914
+ if (!folder.entry) {
915
+ it.notes.push('Its source names no entry for it, so every file of its folder was pulled')
916
+ warnings.push(`Function ${row.slug}: no file in its source declares \`automation({ name: '${row.slug}' })\`; its whole folder was pulled`)
917
+ }
679
918
  }
680
919
  // Functions name other resources only in their code, if at all; nothing to rewrite.
681
920
  it.requires = []
682
921
  out.push(it)
683
922
  }
923
+ if (missingImports.length) await recoverMissingImports(missingImports, out, (path) => source.workspace.getBytes(path), warnings)
684
924
  noteSharedFunctionFiles(out, warnings)
685
925
  return out
686
926
  }
@@ -696,7 +936,7 @@ export function noteSharedFunctionFiles(items: RemoteItem[], warnings: string[])
696
936
  for (const it of items) for (const path of Object.keys(it.files)) holders.set(path, [...(holders.get(path) ?? []), it])
697
937
  for (const [path, list] of holders) {
698
938
  if (list.length < 2 || new Set(list.map((i) => i.files[path])).size < 2) continue
699
- const names = list.map((i) => i.name).sort()
939
+ const names = list.map((i) => i.name).sort((a, b) => (String(a) < String(b) ? -1 : String(a) > String(b) ? 1 : 0))
700
940
  const and = (words: string[]) => (words.length < 2 ? words.join('') : `${words.slice(0, -1).join(', ')} and ${words[words.length - 1]}`)
701
941
  for (const it of list) {
702
942
  const others = names.filter((n) => n !== it.name)
@@ -771,6 +1011,13 @@ async function readSkills(source: SyncSource, lists: Lists, opts: ReadOptions, w
771
1011
  return out
772
1012
  }
773
1013
 
1014
+ /** A number a file holds as one: `"0.5"` (a decimal the platform answers as text) is 0.5; anything else is left out or kept as it is. */
1015
+ export function fileNumber(value: unknown): number | undefined {
1016
+ if (typeof value === 'number') return Number.isFinite(value) ? value : undefined
1017
+ if (typeof value === 'string' && value.trim() && Number.isFinite(Number(value))) return Number(value)
1018
+ return value === null || value === undefined ? undefined : (value as number)
1019
+ }
1020
+
774
1021
  async function readKnowledge(source: SyncSource, lists: Lists, opts: ReadOptions, warnings: string[]): Promise<RemoteItem[]> {
775
1022
  const out: RemoteItem[] = []
776
1023
  for (const k of lists.knowledge.filter((x) => wanted(opts, `knowledge/${refName(x.name)}`))) {
@@ -780,17 +1027,18 @@ async function readKnowledge(source: SyncSource, lists: Lists, opts: ReadOptions
780
1027
  description: k.description ?? undefined,
781
1028
  retrieval: k.retrieval ?? undefined,
782
1029
  chunker: k.chunker ?? undefined,
783
- chunkSize: k.chunkSize ?? undefined,
784
- chunkOverlap: k.chunkOverlap ?? undefined,
785
- similarityThreshold: k.similarityThreshold ?? undefined,
786
- topK: k.topK ?? undefined,
1030
+ // Numbers as the file format has them: the platform reads a decimal column back as text ("0.5").
1031
+ chunkSize: fileNumber(k.chunkSize),
1032
+ chunkOverlap: fileNumber(k.chunkOverlap),
1033
+ similarityThreshold: fileNumber(k.similarityThreshold),
1034
+ topK: fileNumber(k.topK),
787
1035
  // How it searches and reads documents: hybrid search and reranking, extraction, OCR.
788
1036
  hybridConfig: k.hybridConfig ?? undefined,
789
1037
  extractorConfig: k.extractorConfig ?? undefined,
790
1038
  ocrConfig: k.ocrConfig ?? undefined,
791
1039
  }).filter(([, v]) => v !== undefined))
792
1040
  const it = item('knowledge', name, k.name, { [`knowledge/${name}/knowledge.json`]: json(doc) }, {
793
- notes: ['Its documents stay on the platform: add them as Markdown or text files in this folder and `local dev` uploads them'],
1041
+ notes: ['Its documents come with it when it is pulled (unless `--no-documents`), and `local dev` uploads them; Markdown or text files added to this folder are uploaded too'],
794
1042
  requires: [],
795
1043
  id: k.id,
796
1044
  })
@@ -898,7 +1146,36 @@ async function readPlugins(lists: Lists, opts: ReadOptions, tools: PluginTools):
898
1146
  return out
899
1147
  }
900
1148
 
901
- interface BundleAnswer { definition: Parameters<typeof toFiles>[0] | null; datasetNames: Record<string, string> }
1149
+ interface BundleAnswer {
1150
+ definition: Parameters<typeof toFiles>[0] | null
1151
+ datasetNames: Record<string, string>
1152
+ /** The workspace's own grants beyond read (the workspace's slice only): `blueprint/workspace-grants.yaml`. */
1153
+ workspaceGrants?: Array<{ objectType?: unknown; permissions?: unknown }>
1154
+ }
1155
+
1156
+ /**
1157
+ * `blueprint/workspace-grants.yaml` as the workspace's slice answers it: the
1158
+ * types the workspace holds more than `read` on (today, `read_sensitive`), in
1159
+ * the file's own shape (`local-workspace-grants.ts`). None: no item, so a
1160
+ * pull writes no file.
1161
+ */
1162
+ export function workspaceGrantsItem(grants: BundleAnswer['workspaceGrants']): RemoteItem | null {
1163
+ const entries = (Array.isArray(grants) ? grants : []).flatMap((g) => {
1164
+ const type = typeof g?.objectType === 'string' ? g.objectType.replace(/^objectType\//, '') : ''
1165
+ const permissions = Array.isArray(g?.permissions) ? g.permissions.filter((p): p is string => typeof p === 'string') : []
1166
+ return type && permissions.length ? [{ objectType: `objectType/${type}`, permissions }] : []
1167
+ })
1168
+ if (!entries.length) return null
1169
+ const doc = new YamlDocument({ grants: entries })
1170
+ // `permissions: [read, read_sensitive]`, as the file is written by hand.
1171
+ for (const entry of (doc.get('grants') as YAMLSeq<YAMLMap>).items) {
1172
+ const permissions = entry.get('permissions', true)
1173
+ if (isSeq(permissions)) permissions.flow = true
1174
+ }
1175
+ const it = item('blueprint', WORKSPACE_GRANTS_NAME, 'Workspace grants', { [WORKSPACE_GRANTS_FILE]: doc.toString({ lineWidth: 0, flowCollectionPadding: false }) })
1176
+ it.requires = entries.map((e) => itemRef('objectType', e.objectType.slice('objectType/'.length))).sort()
1177
+ return it
1178
+ }
902
1179
 
903
1180
  /**
904
1181
  * The saved object sets as Blueprint files (`blueprint/object-sets/<name>.yaml`):
@@ -925,7 +1202,7 @@ export function objectSetItems(sets: ObjectSetRow[], directory: Directory, objec
925
1202
  it.unresolved.push(...unresolved.map((u) => ({ ...u, path: `blueprint/object-sets/${name}.yaml: ${u.path}` })))
926
1203
  it.files[`blueprint/object-sets/${name}.yaml`] = stringifyYaml(named, { lineWidth: 0 })
927
1204
  const others = referencesIn(named).filter((r) => r.kind === 'objectSet' && r.name !== name).map((r) => itemRef(r.kind, r.name))
928
- it.requires = [itemRef('objectType', set.objectType), ...new Set(others)].sort()
1205
+ it.requires = [itemRef('objectType', set.objectType), ...new Set(others)].sort((a, b) => (String(a) < String(b) ? -1 : String(a) > String(b) ? 1 : 0))
929
1206
  if (set.kind === 'static') {
930
1207
  const n = set.staticPks?.length ?? 0
931
1208
  it.notes.push(`Lists ${n} ${set.objectType} record${n === 1 ? '' : 's'} by key; locally it holds those of them this runtime’s data has`)
@@ -971,6 +1248,8 @@ async function readBlueprint(
971
1248
  let answer: BundleAnswer | null = null
972
1249
  // The draft read in place of the release, already holding its Actions.
973
1250
  let fromDraft = false
1251
+ // The workspace's grants beyond read, from its slice (the organization's model has none of a workspace's).
1252
+ let grants: RemoteItem | null = null
974
1253
  if (mode === 'organization' && source.organization) {
975
1254
  const lifecycle = one<Lifecycle>(await source.organization.get('/v1/blueprint/lifecycle'))
976
1255
  fromDraft = !!lifecycle.draft?.definition && (!!opts.draft || !lifecycle.activeRelease?.definition)
@@ -978,8 +1257,10 @@ async function readBlueprint(
978
1257
  if (fromDraft) notes.push(opts.draft ? 'The Blueprint was read from its unpublished draft, as asked' : 'The Blueprint has no published release; its draft was read')
979
1258
  } else {
980
1259
  const sliced = await source.workspace.find<unknown>('/v1/sync/blueprint')
981
- if (sliced) answer = one<BundleAnswer>(sliced)
982
- else if (source.organization) {
1260
+ if (sliced) {
1261
+ answer = one<BundleAnswer>(sliced)
1262
+ grants = workspaceGrantsItem(answer.workspaceGrants)
1263
+ } else if (source.organization) {
983
1264
  notes.push('This platform cannot cut the Blueprint to the workspace yet; the organization key read the whole model')
984
1265
  return readBlueprint(source, 'organization', warnings, notes, sets, opts)
985
1266
  } else {
@@ -994,7 +1275,7 @@ async function readBlueprint(
994
1275
  if (lifecycle.draft?.definition) {
995
1276
  answer = { definition: sliceDraft(lifecycle.draft.definition, granted), datasetNames: { ...(answer.datasetNames ?? {}), ...(await datasetNames(source.organization)) } }
996
1277
  fromDraft = true
997
- notes.push('The Blueprint was read from its unpublished draft, as asked, cut to the object types this workspace is granted')
1278
+ notes.push('The Blueprint was read from its unpublished draft, as asked, cut to the object types the workspace is granted')
998
1279
  }
999
1280
  }
1000
1281
  }
@@ -1003,6 +1284,8 @@ async function readBlueprint(
1003
1284
  // With the organization key, the published Actions on the slice's object types come along.
1004
1285
  const definition = mode === 'workspace' && source.organization && !fromDraft ? await withActions(source.organization, answer.definition, warnings, notes) : answer.definition
1005
1286
  const model = toFiles(definition, new Map(Object.entries(answer.datasetNames ?? {})))
1287
+ // What an Action is checked against, as the local runtime checks it when `local dev` writes it.
1288
+ const semantic = semanticFactsOf(model.files.map((f) => ({ path: f.path, document: f.document as Record<string, unknown> })))
1006
1289
  const objectTypes = new Set(model.files.flatMap((f) => (f.path.startsWith('object-types/') ? [f.apiName] : [])))
1007
1290
  const saved = objectSetItems(sets.lists.objectSets ?? [], sets.directory, objectTypes)
1008
1291
  return [...model.files.map((file) => {
@@ -1013,12 +1296,20 @@ async function readBlueprint(
1013
1296
  if (file.path.startsWith('actions/')) {
1014
1297
  // An Action brings the object types it acts on, and says what it does on the local runtime.
1015
1298
  const doc = file.document as Record<string, any>
1016
- const fit = actionFit(doc)
1299
+ const fit = actionFit(doc, semantic)
1017
1300
  if (fit.subject) it.requires = [itemRef('objectType', fit.subject)]
1018
- it.notes.push(fit.runsLocally ? `Deployed on the local runtime when it applies: it writes ${fit.subject ?? 'its object'}’s records there` : `Published switched off on the local runtime: ${fit.reason}`)
1301
+ it.notes.push(
1302
+ fit.refused
1303
+ ? `Won’t run on the local runtime: ${fit.reason!.replace(/^the local runtime refuses it: /, 'it refuses it as written. ')} The fix is the Blueprint author’s, in the workspace: change the Action or the semantic type so they agree, then pull it`
1304
+ : fit.runsLocally
1305
+ ? `Deployed on the local runtime when it applies: it writes ${fit.subject ?? 'its object'}’s records there`
1306
+ : `Published switched off on the local runtime: ${fit.reason}`,
1307
+ )
1308
+ // As the workspace runs it, a value it offers that the type it writes refuses: inherited, not refused here.
1309
+ if (fit.inheritedWarnings?.length) it.notes.push(`Runs with a warning inherited from the workspace: ${fit.inheritedWarnings.join(' ')} A request with such a value is refused when it is submitted, here as in the workspace; the Blueprint author fixes the Action’s contract there, then you pull it`)
1019
1310
  }
1020
1311
  return it
1021
- }), ...saved]
1312
+ }), ...saved, ...(grants ? [grants] : [])]
1022
1313
  }
1023
1314
 
1024
1315
  type Bundle = NonNullable<BundleAnswer['definition']>
@@ -1048,7 +1339,7 @@ async function withActions(org: SyncClient, sliced: Bundle, warnings: string[],
1048
1339
  if (touched.every((id) => ids.has(id))) kept.push(action)
1049
1340
  else outside.push(String(action.apiName))
1050
1341
  }
1051
- if (outside.length) notes.push(`${outside.length} governed Action${outside.length === 1 ? ' acts' : 's act'} on object types this workspace isn’t granted, so ${outside.length === 1 ? 'it stays' : 'they stay'} out: ${outside.join(', ')}`)
1342
+ if (outside.length) notes.push(`${outside.length} governed Action${outside.length === 1 ? ' acts' : 's act'} on object types the workspace isn’t granted, so ${outside.length === 1 ? 'it stays' : 'they stay'} out: ${outside.join(', ')}`)
1052
1343
  return { ...def, actions: kept } as Bundle
1053
1344
  }
1054
1345
 
@@ -1304,7 +1595,7 @@ async function readEvaluations(source: SyncSource, lists: Lists, directory: Dire
1304
1595
  const file = `agents/${slug}/evals/${slugify(ds.name)}.yaml`
1305
1596
  const header = `# Test cases from the platform's evaluation dataset "${ds.name}" (frontera local sync).\n`
1306
1597
  const doc = { ...(isRegression ? { regression: true } : {}), cases }
1307
- out.push(item('evaluation', `${slug}.${slugify(ds.name)}`, `${ds.name} → ${slug}`, { [file]: header + stringifyYaml(doc, { lineWidth: 0 }) }, { notes, requires: [...requires].sort() }))
1598
+ out.push(item('evaluation', `${slug}.${slugify(ds.name)}`, `${ds.name} → ${slug}`, { [file]: header + stringifyYaml(doc, { lineWidth: 0 }) }, { notes, requires: [...requires].sort((a, b) => (String(a) < String(b) ? -1 : String(a) > String(b) ? 1 : 0)) }))
1308
1599
  }
1309
1600
  }
1310
1601
  return out
@@ -1375,9 +1666,29 @@ export async function readWorkspace(source: SyncSource, opts: ReadOptions = {}):
1375
1666
  noteConnections(items, lists)
1376
1667
  noteActions(items, directory)
1377
1668
  // What each item needs against what the local runtime can do (§8), with the Actions this read brought.
1378
- const actions = new Map(items.filter((i) => i.kind === 'blueprint' && i.name.startsWith('actions/')).flatMap((i) => Object.values(i.files).map((text) => [i.name.slice('actions/'.length), actionFit((parseYaml(text) ?? {}) as Record<string, unknown>)] as const)))
1669
+ // The checks `local dev` will apply, run here so the index says what won't run before anything is applied:
1670
+ // an Action's values against the semantic types it writes, and the files a Function's code imports.
1671
+ const semantic = semanticFactsOf(items.filter((i) => i.kind === 'blueprint').flatMap((i) => Object.entries(i.files).flatMap(([path, text]) => {
1672
+ try {
1673
+ const document = parseYaml(text)
1674
+ return document && typeof document === 'object' ? [{ path, document: document as Record<string, unknown> }] : []
1675
+ } catch {
1676
+ return []
1677
+ }
1678
+ })))
1679
+ const actions = new Map(items.filter((i) => i.kind === 'blueprint' && i.name.startsWith('actions/')).flatMap((i) => Object.values(i.files).map((text) => [i.name.slice('actions/'.length), actionFit((parseYaml(text) ?? {}) as Record<string, unknown>, semantic)] as const)))
1379
1680
  const titles = new Map(lists.apps.flatMap((a) => (a.install && a.catalog?.displayName ? [[a.install.installName, a.catalog.displayName] as const] : [])))
1380
- for (const it of items) it.portability = portabilityOf(it.kind, it.files, { connections: connectionNames(lists), builtIns: builtInKinds(lists), context: { actions, titles } })
1681
+ const pulledFiles = new Set(items.flatMap((i) => Object.keys(i.files)))
1682
+ const hasFile = (path: string) => pulledFiles.has(path)
1683
+ for (const it of items) it.portability = portabilityOf(it.kind, it.files, { connections: connectionNames(lists), builtIns: builtInKinds(lists), context: { actions, titles }, semantic, hasFile })
1684
+ // A Function's code that names an object type by the workspace's id: the local runtime's ids differ.
1685
+ const platformIds = platformObjectTypeIds(directory)
1686
+ for (const it of items) {
1687
+ if (it.kind !== 'function') continue
1688
+ const hits = hardCodedObjectTypeIds(it.files, platformIds)
1689
+ if (!hits.length) continue
1690
+ it.notes.push(`Hard-codes the workspace’s object type ids, which the local runtime doesn’t share: ${hits.map((h) => `${h.file}:${h.line} (${h.objectType})`).join(', ')}. \`local dev\` deploys it here with this runtime’s ids in their place and leaves the files as they are; take the id from the query result (\`subjectOf\`, or \`result.objectTypeId\`) so the code holds on any runtime`)
1691
+ }
1381
1692
  if (!source.organization && items.some((i) => i.unresolved.length)) {
1382
1693
  notes.push(directory.model?.size
1383
1694
  ? 'Governed Actions are named only with an organization key (they are organization-wide); an Action step shows as an id until you connect one'