@frontera-sdk/cli 1.52.39 → 1.52.40

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (225) hide show
  1. package/README.md +9 -0
  2. package/package.json +6 -4
  3. package/src/adopt.ts +6 -2
  4. package/src/api/apps-api.ts +86 -4
  5. package/src/api/blueprint-authoring-api.ts +35 -11
  6. package/src/api/dataset-api.ts +29 -0
  7. package/src/api/governed-action-api.ts +90 -2
  8. package/src/api/platform-api.ts +87 -2
  9. package/src/api/workflow-api.ts +33 -2
  10. package/src/app-contract.ts +156 -0
  11. package/src/app-lock.ts +125 -0
  12. package/src/args.ts +14 -3
  13. package/src/auth-verify.ts +10 -0
  14. package/src/automation-template.ts +12 -1
  15. package/src/blueprint/action-rules.ts +516 -0
  16. package/src/blueprint/action-semantic-fit.ts +205 -0
  17. package/src/blueprint/compile.ts +51 -2
  18. package/src/blueprint/diff.ts +34 -2
  19. package/src/blueprint/metric-query.ts +215 -0
  20. package/src/blueprint/metric-rules.ts +122 -0
  21. package/src/blueprint/object-sets.ts +116 -0
  22. package/src/blueprint/ontology-edit-plan.ts +48 -4
  23. package/src/blueprint/projection.ts +24 -1
  24. package/src/blueprint/scaffold.ts +84 -42
  25. package/src/commands/action/available.ts +1 -1
  26. package/src/commands/action/criteria-refusal.ts +84 -0
  27. package/src/commands/action/decide.ts +5 -2
  28. package/src/commands/action/deploy.ts +13 -4
  29. package/src/commands/action/list.ts +134 -19
  30. package/src/commands/action/requests.ts +34 -6
  31. package/src/commands/action/submit.ts +28 -13
  32. package/src/commands/agent/index-commands.ts +69 -7
  33. package/src/commands/agent/resolve.ts +2 -0
  34. package/src/commands/app/checks.ts +44 -0
  35. package/src/commands/app/deploy.ts +243 -60
  36. package/src/commands/app/deployments.ts +97 -0
  37. package/src/commands/app/dev.ts +103 -11
  38. package/src/commands/app/init.ts +36 -3
  39. package/src/commands/app/promote.ts +45 -19
  40. package/src/commands/app/pull.ts +3 -1
  41. package/src/commands/app/rollback.ts +46 -0
  42. package/src/commands/app/sdk.ts +54 -8
  43. package/src/commands/app/unpin.ts +27 -0
  44. package/src/commands/app/versions.ts +1 -1
  45. package/src/commands/automation/bindings.ts +4 -2
  46. package/src/commands/automation/build-entry.ts +4 -1
  47. package/src/commands/automation/index-commands.ts +3 -1
  48. package/src/commands/automation/pull.ts +6 -1
  49. package/src/commands/automation/run.ts +34 -9
  50. package/src/commands/blueprint/bind.ts +2 -1
  51. package/src/commands/blueprint/declarative.ts +331 -31
  52. package/src/commands/blueprint/get.ts +6 -2
  53. package/src/commands/blueprint/grants.ts +64 -10
  54. package/src/commands/blueprint/metric.ts +108 -0
  55. package/src/commands/blueprint/object-sets.ts +71 -0
  56. package/src/commands/blueprint/query.ts +155 -31
  57. package/src/commands/dataset/index-commands.ts +156 -7
  58. package/src/commands/knowledge/index-commands.ts +122 -20
  59. package/src/commands/local/check.ts +657 -44
  60. package/src/commands/local/data.ts +33 -12
  61. package/src/commands/local/dev.ts +875 -88
  62. package/src/commands/local/docs-mcp.ts +10 -18
  63. package/src/commands/local/index-commands.ts +300 -63
  64. package/src/commands/local/init.ts +18 -5
  65. package/src/commands/local/key.ts +0 -3
  66. package/src/commands/local/providers.ts +0 -7
  67. package/src/commands/local/remove.ts +116 -0
  68. package/src/commands/local/schema.ts +3 -4
  69. package/src/commands/local/sdk-refresh.ts +59 -0
  70. package/src/commands/local/snapshot.ts +100 -0
  71. package/src/commands/local/sync-request.ts +320 -13
  72. package/src/commands/local/sync.ts +102 -5
  73. package/src/commands/mcp/serve.ts +34 -0
  74. package/src/commands/registry.ts +52 -8
  75. package/src/commands/types.ts +29 -0
  76. package/src/commands/workflow/archive.ts +46 -0
  77. package/src/commands/workflow/get.ts +2 -1
  78. package/src/commands/workflow/index-commands.ts +5 -0
  79. package/src/commands/workflow/push.ts +49 -2
  80. package/src/commands/workflow/run.ts +65 -7
  81. package/src/commands/workflow/runs.ts +9 -6
  82. package/src/commands/workflow/webhook-test.ts +166 -0
  83. package/src/config.ts +43 -6
  84. package/src/dev-broker.ts +52 -0
  85. package/src/exit.ts +103 -4
  86. package/src/flag-help.ts +77 -17
  87. package/src/forge/touches.ts +15 -12
  88. package/src/function-outside-imports.ts +89 -0
  89. package/src/harness.ts +42 -24
  90. package/src/heal.ts +112 -1
  91. package/src/local/fix-words.ts +12 -0
  92. package/src/local/function-bundle-recovery.ts +775 -0
  93. package/src/local/local-actions.ts +103 -10
  94. package/src/local/local-agent-action-grants.ts +210 -0
  95. package/src/local/local-agent-checks.ts +232 -0
  96. package/src/local/local-agent-grants.ts +29 -7
  97. package/src/local/local-agent-lean-plugins.ts +68 -0
  98. package/src/local/local-agent-missing-tools.ts +68 -0
  99. package/src/local/local-agent-name.ts +27 -0
  100. package/src/local/local-app-code.ts +191 -0
  101. package/src/local/local-app-dev-files.ts +186 -0
  102. package/src/local/local-app-dev-session.ts +88 -0
  103. package/src/local/local-app-lock-install.ts +114 -0
  104. package/src/local/local-app-pulled.ts +167 -0
  105. package/src/local/local-app-restore.ts +111 -0
  106. package/src/local/local-app-runtime.ts +68 -0
  107. package/src/local/local-app-sdk-paths.ts +149 -0
  108. package/src/local/local-app-sdk.ts +509 -0
  109. package/src/local/local-applied.ts +211 -21
  110. package/src/local/local-apply-history.ts +84 -0
  111. package/src/local/local-apply-summary.ts +86 -0
  112. package/src/local/local-apply-watchdog.ts +95 -0
  113. package/src/local/local-apps.ts +180 -7
  114. package/src/local/local-blueprint-errors.ts +198 -0
  115. package/src/local/local-blueprint-outline.ts +31 -12
  116. package/src/local/local-blueprint-schemas.ts +582 -0
  117. package/src/local/local-build-inputs.ts +48 -0
  118. package/src/local/local-build-summary.ts +495 -28
  119. package/src/local/local-case-evidence.ts +120 -0
  120. package/src/local/local-case-not-run.ts +153 -0
  121. package/src/local/local-chat-apps.ts +93 -16
  122. package/src/local/local-check-inherited.ts +83 -0
  123. package/src/local/local-cities.ts +89 -0
  124. package/src/local/local-component-health.ts +119 -0
  125. package/src/local/local-control.ts +34 -7
  126. package/src/local/local-db-snapshot.ts +348 -0
  127. package/src/local/local-dev-references.ts +346 -35
  128. package/src/local/local-dev-sync.ts +136 -7
  129. package/src/local/local-docker.ts +35 -1
  130. package/src/local/local-docs-search.ts +1 -1
  131. package/src/local/local-docs.ts +281 -2
  132. package/src/local/local-empty-workflows.ts +92 -0
  133. package/src/local/local-env.ts +16 -1
  134. package/src/local/local-file-checks.ts +104 -0
  135. package/src/local/local-function-ids.ts +141 -0
  136. package/src/local/local-function-recovery.ts +141 -0
  137. package/src/local/local-function-state.ts +87 -0
  138. package/src/local/local-keys.ts +7 -5
  139. package/src/local/local-knowledge-ingestion.ts +225 -0
  140. package/src/local/local-knowledge-uploads.ts +217 -0
  141. package/src/local/local-media-placeholders.ts +182 -0
  142. package/src/local/local-media-sets.ts +90 -10
  143. package/src/local/local-model-parameters.ts +89 -0
  144. package/src/local/local-object-sets.ts +18 -6
  145. package/src/local/local-operating.ts +35 -1
  146. package/src/local/local-org-settings.ts +205 -7
  147. package/src/local/local-orphans.ts +321 -0
  148. package/src/local/local-personas.ts +296 -0
  149. package/src/local/local-portability.ts +141 -16
  150. package/src/local/local-ports.ts +99 -7
  151. package/src/local/local-project-notices.ts +234 -0
  152. package/src/local/local-project-secrets.ts +133 -0
  153. package/src/local/local-project.ts +12 -0
  154. package/src/local/local-provider-refusal.ts +66 -0
  155. package/src/local/local-request-evidence.ts +112 -0
  156. package/src/local/local-runtime-health.ts +123 -0
  157. package/src/local/local-runtime-history.ts +50 -0
  158. package/src/local/local-runtime-target.ts +42 -0
  159. package/src/local/local-sample-data.ts +141 -0
  160. package/src/local/local-sample-remake.ts +138 -0
  161. package/src/local/local-scaffold.ts +336 -53
  162. package/src/local/local-schema-check.ts +148 -0
  163. package/src/local/local-schemas.ts +742 -25
  164. package/src/local/local-secret-carry.ts +107 -0
  165. package/src/local/local-secrets.ts +38 -8
  166. package/src/local/local-seed.ts +102 -26
  167. package/src/local/local-stand-ins.ts +1 -1
  168. package/src/local/local-state.ts +43 -2
  169. package/src/local/local-studio-server.ts +170 -7
  170. package/src/local/local-supervisor.ts +44 -6
  171. package/src/local/local-sync-names.ts +2 -0
  172. package/src/local/local-sync-plan.ts +11 -0
  173. package/src/local/local-sync-push.ts +10 -7
  174. package/src/local/local-sync-refs.ts +49 -3
  175. package/src/local/local-sync-remote.ts +343 -32
  176. package/src/local/local-sync-snapshot.ts +15 -10
  177. package/src/local/local-synthetic-data.ts +2116 -99
  178. package/src/local/local-test-baseline.ts +113 -0
  179. package/src/local/local-test-cases.ts +76 -1
  180. package/src/local/local-test-runner.ts +582 -91
  181. package/src/local/local-test-runs.ts +13 -5
  182. package/src/local/local-vendored-sdk.ts +183 -0
  183. package/src/local/local-workflow-state.ts +16 -3
  184. package/src/local/local-workflow-tests.ts +482 -0
  185. package/src/local/local-workspace-grants.ts +81 -0
  186. package/src/main.ts +50 -9
  187. package/src/mcp/a11y-scan.ts +55 -0
  188. package/src/mcp/browser/ax-snapshot.ts +181 -0
  189. package/src/mcp/browser/credentials.ts +78 -0
  190. package/src/mcp/browser/frames.ts +336 -0
  191. package/src/mcp/browser/headless-session.ts +413 -0
  192. package/src/mcp/browser/headless-tab.ts +575 -0
  193. package/src/mcp/browser/keys.ts +70 -0
  194. package/src/mcp/browser/page-scripts.ts +129 -0
  195. package/src/mcp/browser/wait.ts +73 -0
  196. package/src/mcp/forge-link.ts +126 -0
  197. package/src/mcp/headless.ts +199 -0
  198. package/src/mcp/mcp-apps.ts +435 -0
  199. package/src/mcp/mcp-chat.ts +226 -0
  200. package/src/mcp/mcp-cli.ts +53 -0
  201. package/src/mcp/mcp-protocol.ts +122 -0
  202. package/src/mcp/mcp-runtime.ts +113 -0
  203. package/src/mcp/mcp-server.ts +108 -0
  204. package/src/mcp/mcp-traces.ts +409 -0
  205. package/src/mcp/page-observation.ts +193 -0
  206. package/src/mcp/tools/agent-tools.ts +700 -0
  207. package/src/mcp/tools/app-tools.ts +241 -0
  208. package/src/mcp/tools/browser-tools.ts +310 -0
  209. package/src/mcp/tools/docs-tools.ts +32 -0
  210. package/src/mcp/tools/project-tools.ts +205 -0
  211. package/src/mcp/tools/test-tools.ts +339 -0
  212. package/src/mcp/tools/workflow-tools.ts +611 -0
  213. package/src/output.ts +1 -1
  214. package/src/pack.ts +8 -3
  215. package/src/packaging.ts +1 -1
  216. package/src/project-bootstrap.ts +107 -4
  217. package/src/project-context.ts +18 -1
  218. package/src/project.ts +27 -2
  219. package/src/provenance.ts +25 -0
  220. package/src/render-evidence.ts +65 -7
  221. package/src/template.ts +14 -8
  222. package/src/templates/next-app-files.ts +35 -6
  223. package/src/templates/next-skills.ts +61 -27
  224. package/src/vendor/kit-assets.json +6 -6
  225. package/src/vendor/sdk-sources.json +24 -21
@@ -1,13 +1,20 @@
1
1
  import { type ChildProcess, spawn } from 'node:child_process'
2
- import { existsSync, lstatSync, readFileSync, rmSync, watch, writeFileSync } from 'node:fs'
3
- import { join, sep } from 'node:path'
2
+ import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, rmSync, watch, writeFileSync } from 'node:fs'
3
+ import { join, posix, sep } from 'node:path'
4
4
 
5
- import { CliError } from '../../errors'
6
- import { adaptationOf, contentHash, loadBuild, loadHashes, oneLine, pruneBuild, recordBuild, recordSettingsDifferences, saveHashes, type BuildEntry } from '../../local/local-applied'
7
- import { deployActions, ensureActionDraft, localActionCalls, prepareActions, retireActions, staleActions, unpublishedActions, type ActionBuild } from '../../local/local-actions'
8
- import { agentIdentity, syncAgentName } from '../../local/local-agent-name'
5
+ import { parse as parseYaml } from 'yaml'
6
+
7
+ import { CliError, UsageError } from '../../errors'
8
+ import { ensureProjectBunfig } from '../../local/local-scaffold'
9
+ import { llmParameterNotes } from '../../local/local-model-parameters'
10
+ import { findOrphans, orphanCalls, restoreIfArchived, retireDeleted, reviveFunction, runtimeItems } from '../../local/local-orphans'
11
+ import { adaptationOf, contentHash, refOfTarget, failureCodeAndHint, isEnvironmentNote, loadBuild, loadHashes, oneLine, promotedVersionOf, pruneBuild, recordBuild, recordSettingsDifferences, saveHashes, type BuildEntry } from '../../local/local-applied'
12
+ import { INHERITED_FROM_PULL } from '../../blueprint/action-rules'
13
+ import { actionsNotRunning, deployActions, ensureActionDraft, liveActionStates, localActionCalls, prepareActions, retireActions, staleActions, unpublishedActions, type ActionBuild } from '../../local/local-actions'
14
+ import { agentIdentity, hasAgentDraft, syncAgentName } from '../../local/local-agent-name'
15
+ import { agentFileErrors, agentFileWarnings, approvalBindingNotes, boundTools, isApprovalNote, procedureToolProblems } from '../../local/local-agent-checks'
9
16
  import { classify, itemNeeds, type BuiltInKind } from '../../local/local-portability'
10
- import { localProfileName } from '../../local/local-control'
17
+ import { localProfileName, rebindCopiedProject } from '../../local/local-control'
11
18
  import {
12
19
  allTargets,
13
20
  blueprintObjectTypes,
@@ -17,32 +24,52 @@ import {
17
24
  describeTarget,
18
25
  followUpsFor,
19
26
  targetFor,
27
+ unappliedFiles,
28
+ unappliedReason,
20
29
  type SyncProfiles,
21
30
  type SyncTarget,
22
31
  } from '../../local/local-dev-sync'
23
- import { applyBindings, applyKnowledge, applyPlugin, blueprintWithout, editableSets, localDirectory, promoteWorkflow, resolveBlueprint, resolveTarget, type ObjectBuild } from '../../local/local-dev-references'
32
+ import { applyBindings, applyKnowledge, applyPlugin, blueprintWithout, editableSets, embeddingsDiffer, localDirectory, mediaCatalogDatasetReason, pointMediaTypesAt, promoteWorkflow, resolveBlueprint, resolveTarget, type ObjectBuild, type ResolvedBlueprint } from '../../local/local-dev-references'
24
33
  import { applyObjectSets, namesObjectSet, objectSetCalls } from '../../local/local-object-sets'
25
34
  import { applyTheme, themeCalls } from '../../local/local-themes'
26
- import { ensureMediaSets } from '../../local/local-media-sets'
27
- import { applyChatApp } from '../../local/local-chat-apps'
35
+ import { ensureMediaSets, mediaUpload } from '../../local/local-media-sets'
36
+ import { fillPlaceholderMedia } from '../../local/local-media-placeholders'
37
+ import { applyChatApp, keyCalls } from '../../local/local-chat-apps'
28
38
  import { applyLessons } from '../../local/local-lessons'
29
39
  import { applyStandIn, platformOnlyNames, readStandIn, standInNames } from '../../local/local-stand-ins'
30
40
  import { projectProvides } from '../../local/local-adapt'
31
41
  import { applyOperatingConfig } from '../../local/local-operating'
42
+ import { localEmbeddingsReachable } from '../../local/local-keys'
32
43
  import { mirrorOrgSettings, settingsCalls } from '../../local/local-org-settings'
33
- import { readSnapshot } from '../../local/local-sync-snapshot'
34
- import { functionSlugOf, mirrorFunctionState } from '../../local/local-function-state'
35
- import { functionSecretNames, missingSecretLines, readProjectDoc, readProjectText, workflowSecretNames } from '../../local/local-secrets'
36
- import { decideStepNotes, mirrorWorkflowState } from '../../local/local-workflow-state'
44
+ import { directoryOf, readSnapshot } from '../../local/local-sync-snapshot'
45
+ import { BUNDLE_ID_MAP_ENV, bundleIdMap, functionCodeFiles, hardCodedIdLine, hardCodedObjectTypeIds, platformObjectTypeIds } from '../../local/local-function-ids'
46
+ import { checkRunnable, functionSlugOf, mirrorFunctionState } from '../../local/local-function-state'
47
+ import { functionSecretNames, localSecretPresence, missingSecretLines, readProjectDoc, readProjectText, workflowSecretNames } from '../../local/local-secrets'
48
+ import { seedPersonas } from '../../local/local-personas'
49
+ import { PROJECT_SECRETS_FILE, projectSecretLines } from '../../local/local-project-secrets'
50
+ import { decideStepNotes, keepLiveCurrent, mirrorWorkflowState } from '../../local/local-workflow-state'
37
51
  import type { Directory } from '../../local/local-sync-refs'
38
52
  import { localProject, type LocalProject } from '../../local/local-project'
39
- import { isAlive, readState, recordApplied } from '../../local/local-state'
53
+ import { isAlive, readState, recordApplied, recordApplying, type LocalCredentials } from '../../local/local-state'
40
54
  import { applyErrorText } from '../../local/local-apply-error'
55
+ import { applyFailures, inheritedApplyLine, splitApplyFailures } from '../../local/local-apply-summary'
56
+ import { unchangedSincePull } from '../../local/local-check-inherited'
57
+ import { readWorkspaceGrants } from '../../local/local-workspace-grants'
58
+ import { ApplyWatchdog, stoppedReapplyingLine } from '../../local/local-apply-watchdog'
59
+ import { hasGeneratedSampleData, pulledSampleData, pulledSampleDataNote, remakeLines, remakeReason, remakeSampleData } from '../../local/local-sample-remake'
60
+ import { appsAwayByRestore } from '../../local/local-app-restore'
61
+ import { BASELINE_RECORD, readBaseline } from '../../local/local-db-snapshot'
62
+ import { canChangeRuntime, filesChangedTrigger, recordApply } from '../../local/local-apply-history'
63
+ import { failedForRuntime, RUNTIME_DOWN_ERROR, RUNTIME_STOPPED_LINE, runtimeAnswering } from '../../local/local-runtime-health'
64
+ import { noticeLines, projectNotices } from '../../local/local-project-notices'
65
+ import { refreshVendoredSdk, vendoredSdkState } from '../../local/local-vendored-sdk'
41
66
  import { flagBool, type Command, type CommandContext } from '../types'
42
67
  import { isSharedFunctionFile } from '../../local/local-function-shared'
43
68
  import { applyApp } from '../../local/local-apps'
44
69
  import { applyBrand, brandUpload } from '../../local/local-brand'
45
70
  import { applyLibrary } from '../../local/local-library'
71
+ import { projectFileChecks } from '../../local/local-file-checks'
72
+ import { actionFailureLines, dedupeLines, hasIndexedPaths, nameIndexedPaths, refusedArtifact, refusedByRules, sourceSystemHint } from '../../local/local-blueprint-errors'
46
73
 
47
74
  /**
48
75
  * `frontera local dev`: the inner loop. Applies the whole project to the
@@ -55,6 +82,9 @@ import { applyLibrary } from '../../local/local-library'
55
82
 
56
83
  const DEBOUNCE_MS = 300
57
84
 
85
+ /** How many Blueprint artifacts one apply may leave off for the runtime's refusals before it says the Blueprint failed. */
86
+ const MAX_REFUSED = 12
87
+
58
88
  interface RunResult {
59
89
  ok: boolean
60
90
  output: string
@@ -62,11 +92,19 @@ interface RunResult {
62
92
  missing?: string[]
63
93
  unnamedActions?: number
64
94
  /** The Blueprint: how the runtime holds each object type and governed Action. */
65
- blueprint?: { objects: Record<string, ObjectBuild>; actions: Record<string, ActionBuild>; objectSets?: string[] }
95
+ blueprint?: { objects: Record<string, ObjectBuild>; actions: Record<string, ActionBuild>; objectSets?: string[]; objectSetIds?: Record<string, string>; parts?: Record<string, { reason: string; refused: boolean }> }
66
96
  /** Saved object sets made again here under a new id: what names them is applied again. */
67
97
  objectSetsAgain?: string[]
68
98
  /** Governed Actions authored again under new ids: what names them is applied again. */
69
99
  authoredAgain?: string[]
100
+ /** The failure in full, one line each, when every line is a thing to fix (each Action that didn't deploy, each failed check). */
101
+ failureLines?: string[]
102
+ /** A dataset got a new revision: the Blueprint is applied again so its object types read it. */
103
+ datasetRevised?: boolean
104
+ /** A workflow: the version the push stored its file as. */
105
+ version?: number
106
+ /** Output `warning:` lines that are the platform's findings on the files (an Agent's behavior settings): they hold wherever it runs. */
107
+ validationWarnings?: string[]
70
108
  }
71
109
 
72
110
  /** The commands running now, so stopping `local dev` stops them too. */
@@ -97,7 +135,7 @@ function withLocalAuth(args: string[], auth: LocalAuth | undefined): { args: str
97
135
  }
98
136
 
99
137
  /** Run this CLI once more, at low priority, in the solution folder. */
100
- function runCli(project: LocalProject, command: string[], auth?: LocalAuth): Promise<RunResult> {
138
+ function runCli(project: LocalProject, command: string[], auth?: LocalAuth, extraEnv: Record<string, string> = {}): Promise<RunResult> {
101
139
  const compiled = Bun.main.startsWith('/$bunfs') || Bun.main.startsWith('B:\\~BUN')
102
140
  const self = compiled ? [process.execPath] : [process.execPath, Bun.main]
103
141
  const { args, env } = withLocalAuth(command, auth)
@@ -105,7 +143,7 @@ function runCli(project: LocalProject, command: string[], auth?: LocalAuth): Pro
105
143
  const child = spawn('nice', ['-n', '10', ...self, ...args], {
106
144
  cwd: project.root,
107
145
  stdio: ['ignore', 'pipe', 'pipe'],
108
- env: { ...process.env, ...env, NO_COLOR: '1' },
146
+ env: { ...process.env, ...env, ...extraEnv, NO_COLOR: '1' },
109
147
  })
110
148
  children.add(child)
111
149
  let output = ''
@@ -125,6 +163,8 @@ interface ApplyOptions {
125
163
  publish: boolean
126
164
  workspaceId: string
127
165
  serviceUrl: string
166
+ /** A line said now, while an apply waits (a Function registering). */
167
+ note?: (line: string) => void
128
168
  }
129
169
 
130
170
  async function apply(project: LocalProject, target: SyncTarget, opts: ApplyOptions): Promise<RunResult> {
@@ -133,12 +173,25 @@ async function apply(project: LocalProject, target: SyncTarget, opts: ApplyOptio
133
173
  // Names in the file (`function/refund-eligibility`) become this runtime's ids in a resolved copy.
134
174
  let directory: Promise<Directory> | null = null
135
175
  const lookup = () => (directory ??= creds ? localDirectory(opts.serviceUrl, creds) : Promise.resolve({}))
176
+ // What the Agent's files say that would apply in silence and fail in a chat (`local-agent-checks.ts`).
177
+ const agent = agentChecks(project, target)
178
+ // A Chat App, knowledge base or skill against its format: a key the platform drops is said, a wrong shape refused.
179
+ const formats = projectFileChecks(project.root, target as { kind: string; file?: string })
180
+ const checked = { errors: [...agent.errors, ...formats.errors], warnings: [...agent.warnings, ...formats.warnings] }
181
+ if (checked.errors.length) return { ok: false, output: [`error [USAGE]: ${describeTarget(target)} can't be applied as written`, ...checked.errors.map((e) => ` ${e}`), ...checked.warnings].join('\n') }
136
182
  const resolved = await resolveTarget(project, target, lookup, creds?.workspaceId, creds?.organizationId)
137
183
  // The project's own file, before a resolved copy takes its place.
138
184
  const original = target as Extract<SyncTarget, { file: string }>
139
185
  if (!resolved.ok) return { ok: false, output: resolved.output, missing: resolved.missing, unnamedActions: resolved.unnamedActions }
140
- let notes = resolved.output
186
+ let notes = [...checked.warnings, resolved.output].filter(Boolean).join('\n')
141
187
  const withNotes = (r: RunResult): RunResult => (notes ? { ...r, output: `${notes}\n${r.output}`.trim() } : r)
188
+ // An Agent or workflow archived here (its file was deleted and is back, or it was archived by hand): restored first,
189
+ // so the file reaches an item in service (`local-orphans.ts`).
190
+ if ((target.kind === 'agent' || target.kind === 'workflow') && creds) {
191
+ const call = orphanCalls(opts.serviceUrl, creds)
192
+ const line = call ? await restoreIfArchived(project, creds.organizationId, call, target.kind, target.slug) : null
193
+ if (line) notes = [notes, line].filter(Boolean).join('\n')
194
+ }
142
195
  if (target.kind === 'operating') {
143
196
  // Read fresh: the owner session is renewed on every runtime start.
144
197
  if (!creds) return { ok: false, output: 'error [LOCAL_NOT_SEEDED]: the runtime is not seeded yet' }
@@ -146,9 +199,9 @@ async function apply(project: LocalProject, target: SyncTarget, opts: ApplyOptio
146
199
  }
147
200
  if (target.kind === 'bindings') {
148
201
  if (!creds) return { ok: false, output: 'error [LOCAL_NOT_SEEDED]: the runtime is not seeded yet' }
149
- return applyBindings(project, target, opts.serviceUrl, creds)
202
+ return withNotes(await applyBindings(project, target, opts.serviceUrl, creds))
150
203
  }
151
- if (target.kind === 'knowledge') return applyKnowledge(project, target, profiles, (args) => runCli(project, args, opts.auth), creds ? { serviceUrl: opts.serviceUrl, creds } : undefined)
204
+ if (target.kind === 'knowledge') return withNotes(await applyKnowledge(project, target, profiles, (args) => runCli(project, args, opts.auth), creds ? { serviceUrl: opts.serviceUrl, creds } : undefined))
152
205
  if (target.kind === 'theme') {
153
206
  if (!creds?.session) return { ok: false, output: 'error [LOCAL_NOT_SEEDED]: the runtime is not seeded yet' }
154
207
  return applyTheme(project.root, target.name, themeCalls(opts.serviceUrl, creds))
@@ -156,7 +209,8 @@ async function apply(project: LocalProject, target: SyncTarget, opts: ApplyOptio
156
209
  if (target.kind === 'chat-app') {
157
210
  if (!creds?.session) return { ok: false, output: 'error [LOCAL_NOT_SEEDED]: the runtime is not seeded yet' }
158
211
  // The owner's session, as the platform's Chat App editor writes; the roster's Agents by this runtime's ids.
159
- return applyChatApp(project.root, target.name, themeCalls(opts.serviceUrl, creds), lookup, projectProvides(project, creds.organizationId))
212
+ // The workspace key's draft too, so `frontera chat-app get` (which reads the key's own draft) shows what was applied.
213
+ return withNotes(await applyChatApp(project.root, target.name, themeCalls(opts.serviceUrl, creds), lookup, projectProvides(project, creds.organizationId), creds.workspaceKey ? keyCalls(opts.serviceUrl, creds) : undefined))
160
214
  }
161
215
  if (target.kind === 'app') {
162
216
  if (!creds?.session) return { ok: false, output: 'error [LOCAL_NOT_SEEDED]: the runtime is not seeded yet' }
@@ -177,49 +231,135 @@ async function apply(project: LocalProject, target: SyncTarget, opts: ApplyOptio
177
231
  }
178
232
  if (target.kind === 'plugin' && (target.file.endsWith('.stand-in.json') || target.file.endsWith('.platform.json'))) return applyStandIn(project, target.name, profiles.workspace, (args) => runCli(project, args, opts.auth))
179
233
  if (target.kind === 'plugin') return applyPlugin(project, target, profiles, (args) => runCli(project, args, opts.auth), creds ? { serviceUrl: opts.serviceUrl, creds } : undefined)
234
+ // A Media Set's catalog: a sample CSV can't publish into it, so the file is skipped, said (sample data makes none).
235
+ if (target.kind === 'dataset') {
236
+ const media = mediaCatalogDatasetReason(project.root, target.apiName)
237
+ if (media) return { ok: true, output: ` warning: dataset ${target.apiName} is skipped here: ${media}` }
238
+ }
180
239
  if (resolved.file) target = { ...target, file: resolved.file } as SyncTarget
181
- let command = commandFor(target, profiles)
240
+ // A Function whose code hard-codes the workspace's object type ids (a pulled one): said, and its
241
+ // bundle here built with this runtime's ids in their place; the files keep the workspace's.
242
+ let bundleEnv: Record<string, string> = {}
243
+ if (target.kind === 'function') {
244
+ const hits = hardCodedObjectTypeIds(functionCodeFiles(project.root, target.file, target.key.endsWith('/')), platformObjectTypeIds(directoryOf(readSnapshot(project.root))))
245
+ if (hits.length) {
246
+ const map = bundleIdMap(hits, creds ? ((await lookup()) as Directory).objectType : undefined)
247
+ if (Object.keys(map).length) bundleEnv = { [BUNDLE_ID_MAP_ENV]: JSON.stringify(map) }
248
+ notes = [notes, ...hits.map((h) => ` warning: ${hardCodedIdLine(h, !!map[h.id.toLowerCase()])}`)].filter(Boolean).join('\n')
249
+ }
250
+ }
251
+ const plain = commandFor(target, profiles)
252
+ let command = plain
182
253
  // Where the applied Blueprint lives: the runtime's copy when it needed one.
183
254
  let blueprintRoot = project.root
184
- const blueprint = target.kind === 'blueprint' ? resolveBlueprint(project) : null
255
+ let blueprint: ResolvedBlueprint | null = null
185
256
  // Governed Actions are deployed on this runtime only (D3): calls to it, with the project's own keys.
186
- const actionCalls = blueprint?.actions.length && creds && opts.publish ? localActionCalls(opts.serviceUrl, creds) : null
187
- if (blueprint) {
188
- // The Blueprint as this runtime can hold it; see `resolveBlueprint`.
189
- if (blueprint.dir) {
190
- command = [...command, '--dir', join(project.root, blueprint.dir)]
191
- blueprintRoot = join(project.root, blueprint.dir, '..')
192
- }
193
- notes = [notes, ...blueprint.warnings].filter(Boolean).join('\n')
194
- // A media-backed type over a set's catalog binds to an empty Media Set of that name, made first.
195
- if (blueprint.mediaSets) {
196
- if (!creds?.session) return { ok: false, output: 'error [LOCAL_NOT_SEEDED]: the runtime is not seeded yet' }
197
- const why = await ensureMediaSets(blueprint.mediaSets, themeCalls(opts.serviceUrl, creds))
198
- if (why) return { ok: false, output: `error [BLUEPRINT_MEDIA]: ${why}` }
199
- }
200
- if (actionCalls) {
201
- // A draft on the first schema cannot hold Actions.
202
- const draft = await ensureActionDraft(actionCalls)
203
- if (!draft.ok) return { ok: false, output: `error [BLUEPRINT_ACTIONS]: ${draft.note}` }
204
- if (draft.note) notes = [notes, ` warning: ${draft.note}`].filter(Boolean).join('\n')
205
- }
206
- }
207
- let result = withNotes(await runCli(project, command, opts.auth))
257
+ let actionCalls: ReturnType<typeof localActionCalls> | null = null
258
+ // What the runtime refused of the Blueprint, one artifact at a time: each is left off and the
259
+ // rest applied again, so one type the runtime cannot hold never keeps the others off it.
260
+ const refused = new Map<string, string>()
261
+ const notesBefore = notes
262
+ let result: RunResult
263
+ for (;;) {
264
+ command = plain
265
+ blueprintRoot = project.root
266
+ notes = notesBefore
267
+ blueprint = target.kind === 'blueprint' ? resolveBlueprint(project, refused) : null
268
+ actionCalls = blueprint?.actions.length && creds && opts.publish ? localActionCalls(opts.serviceUrl, creds) : null
269
+ if (blueprint) {
270
+ // The Blueprint as this runtime can hold it; see `resolveBlueprint`.
271
+ if (blueprint.dir) {
272
+ command = [...command, '--dir', join(project.root, blueprint.dir)]
273
+ blueprintRoot = join(project.root, blueprint.dir, '..')
274
+ }
275
+ notes = [notes, ...blueprint.warnings].filter(Boolean).join('\n')
276
+ // A media-backed type over a set's catalog binds to an empty Media Set of that name, made first,
277
+ // and reads the dataset that set's catalog is here.
278
+ if (blueprint.mediaSets) {
279
+ if (!creds?.session) return { ok: false, output: 'error [LOCAL_NOT_SEEDED]: the runtime is not seeded yet' }
280
+ // This is the project's own runtime: each stand-in set is granted to its workspace.
281
+ const media = await ensureMediaSets(blueprint.mediaSets, themeCalls(opts.serviceUrl, creds), creds.workspaceId)
282
+ if (media.why) return { ok: false, output: `error [BLUEPRINT_MEDIA]: ${media.why}` }
283
+ if (media.warnings.length) notes = [notes, ...media.warnings.map((w) => ` warning: ${w}`)].filter(Boolean).join('\n')
284
+ pointMediaTypesAt(project, blueprint, media.catalogs)
285
+ // An empty stand-in set gets a few generated placeholder files, so the type's rows, links and file reads can be tried.
286
+ if (blueprint.mediaTypes) {
287
+ const placed = await fillPlaceholderMedia(project.root, blueprint.mediaTypes, themeCalls(opts.serviceUrl, creds), mediaUpload(opts.serviceUrl, creds))
288
+ if (placed.length) notes = [notes, ...placed].filter(Boolean).join('\n')
289
+ }
290
+ }
291
+ if (actionCalls) {
292
+ // A draft on the first schema cannot hold Actions.
293
+ const draft = await ensureActionDraft(actionCalls)
294
+ if (!draft.ok) return { ok: false, output: `error [BLUEPRINT_ACTIONS]: ${draft.note}` }
295
+ if (draft.note) notes = [notes, ` warning: ${draft.note}`].filter(Boolean).join('\n')
296
+ }
297
+ }
298
+ result = withNotes(await runCli(project, command, opts.auth, bundleEnv))
299
+ if (blueprint && !result.ok && refused.size < MAX_REFUSED) {
300
+ const stop = refusedArtifact(result.output)
301
+ if (stop && !refused.has(stop.key)) {
302
+ refused.set(stop.key, stop.reason)
303
+ continue
304
+ }
305
+ // Refused before any write, for a rule an Action or metric file breaks: each such file is left off.
306
+ const broken = refusedByRules(result.output).filter((r) => !refused.has(r.key))
307
+ if (broken.length) {
308
+ for (const r of broken) refused.set(r.key, r.reason)
309
+ continue
310
+ }
311
+ }
312
+ break
313
+ }
208
314
  const fallback = fallbackFor(target, profiles, project.root)
209
315
  if (!result.ok && fallback?.when.test(result.output)) {
210
316
  // An empty fallback means there was nothing more to do.
211
317
  result = { ok: true, output: '' }
212
318
  for (const args of fallback.then) {
213
319
  result = await runCli(project, args, opts.auth)
214
- if (!result.ok) return result
320
+ if (!result.ok) break
321
+ }
322
+ // What the resolved copy was applied with (a model swapped, a Plugin it went without) is said
323
+ // when the item is made here too, not only when it is applied again.
324
+ result = withNotes(result)
325
+ // A dataset whose definition changed (a column added, one now nullable): the CSV no longer
326
+ // fits its revision, so the definition becomes the dataset's next revision.
327
+ if (!result.ok && target.kind === 'dataset' && /requires a new revision/i.test(result.output)) {
328
+ const revised = await runCli(project, ['dataset', 'revise', '-f', target.file, '--profile', profiles.organization], opts.auth)
329
+ if (!revised.ok) return { ...revised, output: [result.output.trim(), revised.output.trim()].join('\n') }
330
+ // Each object type reading it is bound to the new revision (a binding is pinned to the revision it was made on).
331
+ const rebound = await rebindReaders(project, target.apiName, profiles, (args) => runCli(project, args, opts.auth))
332
+ if (!rebound.ok) return rebound
333
+ return { ok: true, output: [` cut a new revision of dataset ${target.apiName}: its CSV has empty values the last one refused`, rebound.output].filter(Boolean).join('\n'), datasetRevised: true }
334
+ }
335
+ }
336
+ // Plugins this runtime doesn't have (a newer platform's): the Agent is applied without them, said once.
337
+ if (!result.ok && target.kind === 'agent') {
338
+ const without = withoutPlugins(project, target.file, result.output)
339
+ if (without) {
340
+ notes = [notes, ` warning: ${original.file} names the Plugin${without.names.length === 1 ? '' : 's'} ${without.names.join(', ')}, which this runtime doesn’t have; the Agent is applied without ${without.names.length === 1 ? 'it' : 'them'}`].filter(Boolean).join('\n')
341
+ target = { ...target, file: without.file }
342
+ result = withNotes(await runCli(project, commandFor(target, profiles), opts.auth))
215
343
  }
216
344
  }
345
+ // A Function whose build cannot find a file it imports from beside its folder: said as that, first.
346
+ if (!result.ok && target.kind === 'function') result = { ...result, output: missingImportsOutput(project, original.file, result.output) }
217
347
  if (!result.ok) return result
348
+ if (target.kind === 'workflow') {
349
+ // The version this push stored the file as: what its tests run, and what a promotion makes live.
350
+ const version = pushedVersionOf(result.output)
351
+ if (version) result = { ...result, version }
352
+ }
218
353
  if (target.kind === 'workflow' && creds) {
219
354
  // Live in the workspace: live here too, with its triggers as they are there where that is safe.
355
+ const mirrored = await mirrorWorkflowState(project, target.slug, original.file, opts.serviceUrl, creds, result.version)
220
356
  const lines = [
221
- ...(await mirrorWorkflowState(project, target.slug, original.file, opts.serviceUrl, creds)),
357
+ ...mirrored,
358
+ // Not live in the workspace, but live here (an Agent's binding promoted it): live follows the file.
359
+ ...(mirrored.some((l) => /promoted workflow/.test(l)) ? [] : await keepLiveCurrent(opts.serviceUrl, creds, target.slug, result.version)),
222
360
  ...decideStepNotes(project, original.file, creds),
361
+ // An llm node's `temperature` its model refuses: said now, not when the first run fails at it.
362
+ ...llmParameterNotes(project.root, target.file, ((await lookup()) as Directory).model),
223
363
  // The secrets its HTTP steps and webhook name: placeholders until set on this computer.
224
364
  ...(await missingSecretLines(workflowSecretNames(readProjectDoc(project, original.file)), `workflow ${target.slug}`, opts.serviceUrl, creds)),
225
365
  ]
@@ -228,8 +368,19 @@ async function apply(project: LocalProject, target: SyncTarget, opts: ApplyOptio
228
368
  if (target.kind === 'function' && creds) {
229
369
  // Switched off in the workspace: off here too; and the secrets it is granted, until set here.
230
370
  const source = readProjectText(project, original.file)
371
+ // Switched off here when its file was deleted, and the file is back: switched on, before the workspace's state is mirrored.
372
+ const call = orphanCalls(opts.serviceUrl, creds)
373
+ const revived = call && functionSlugOf(target.key) ? await reviveFunction(project, creds.organizationId, call, functionSlugOf(target.key)!) : null
374
+ const mirrored = await mirrorFunctionState(project, target.key, opts.serviceUrl, creds)
375
+ // Not "applied" until the job engine can run it: a workflow started right after would not find it.
376
+ const runnable = await checkRunnable(opts.serviceUrl, creds, functionSlugOf(target.key) ?? original.file, { note: opts.note, ...runnerAt(project) })
377
+ // A runner registered it, so one is alive: the deploy's "no runner has polled for Ns" (a poll time a
378
+ // restored database or a wait between polls makes old) is not true here, and is not kept.
379
+ if (runnable.runnable === true) result = { ...result, output: result.output.split('\n').filter((l) => !isEnvironmentNote(l)).join('\n') }
231
380
  const lines = [
232
- ...(await mirrorFunctionState(project, target.key, opts.serviceUrl, creds)),
381
+ ...(revived ? [revived] : []),
382
+ ...mirrored,
383
+ ...runnable.lines,
233
384
  ...(await missingSecretLines(functionSecretNames(source), `Function ${functionSlugOf(target.key) ?? original.file}`, opts.serviceUrl, creds)),
234
385
  ]
235
386
  if (lines.length) result = { ...result, output: `${result.output}\n${lines.join('\n')}`.trim() }
@@ -240,6 +391,8 @@ async function apply(project: LocalProject, target: SyncTarget, opts: ApplyOptio
240
391
  if (named) result = { ...result, output: `${result.output}\n${named}`.trim() }
241
392
  }
242
393
  let authoredAgain: string[] = []
394
+ // Object types whose editable fields were declared for their Actions below.
395
+ const editableDone = new Set<string>()
243
396
  if (actionCalls && blueprint) {
244
397
  const names = blueprint.actions.map((a) => a.apiName)
245
398
  // A decision pins the Action's contract, its object type's fields included, and a first
@@ -252,6 +405,7 @@ async function apply(project: LocalProject, target: SyncTarget, opts: ApplyOptio
252
405
  const appliedDir = blueprint.dir ? join(project.root, blueprint.dir) : join(project.root, 'blueprint')
253
406
  const subjects = new Set(blueprint.actions.flatMap((a) => (a.fit.subject ? [a.fit.subject] : [])))
254
407
  for (const [type, props] of editableSets(appliedDir, subjects)) {
408
+ editableDone.add(type)
255
409
  const set = await runCli(project, ['blueprint', 'editable', type, '--set', props.join(','), '--profile', profiles.organization], opts.auth)
256
410
  if (!set.ok && !/no change|already|unchanged/i.test(set.output)) notes = [notes, ` warning: ${type}'s editable fields (${props.join(', ')}) could not be declared: ${oneLine(applyErrorText(set.output))}`].filter(Boolean).join('\n')
257
411
  }
@@ -278,47 +432,251 @@ async function apply(project: LocalProject, target: SyncTarget, opts: ApplyOptio
278
432
  const refused = await prepareActions(actionCalls, names, new Set(authoredAgain))
279
433
  if (refused.size) return { ok: false, output: `error [BLUEPRINT_ACTIONS]: governed Action ${[...refused].map(([name, why]) => `${name}: ${why}`).join('; ')}` }
280
434
  }
435
+ // Every other type's `backing.editable` too, before the publish: an editable type's rows carry
436
+ // their record version, which an Action on it (one to come, or an App's) submits.
437
+ if (blueprint && opts.publish) {
438
+ const appliedDir = blueprint.dir ? join(project.root, blueprint.dir) : join(project.root, 'blueprint')
439
+ for (const [type, props] of editableSets(appliedDir, null)) {
440
+ if (editableDone.has(type)) continue
441
+ const set = await runCli(project, ['blueprint', 'editable', type, '--set', props.join(','), '--profile', profiles.organization], opts.auth)
442
+ if (!set.ok && !/no change|already|unchanged/i.test(set.output)) result = { ...result, output: `${result.output}\n warning: ${type}'s editable fields (${props.join(', ')}) could not be declared: ${oneLine(applyErrorText(set.output))}` }
443
+ }
444
+ }
281
445
  const objectTypes = target.kind === 'blueprint' ? blueprintObjectTypes(blueprintRoot) : []
282
- for (const step of followUpsFor(target, profiles, { publish: opts.publish, workspaceId: opts.workspaceId, objectTypes })) {
446
+ // The workspace's grants beyond read (`blueprint/workspace-grants.yaml`); a file that is wrong fails the Blueprint, said by line.
447
+ const workspaceGrants = target.kind === 'blueprint' ? readWorkspaceGrants(project.root) : null
448
+ if (workspaceGrants?.problems.length) return { ok: false, output: workspaceGrants.problems.map((p) => `error [WORKSPACE_GRANTS]: ${p}`).join('\n') }
449
+ const unknownTypes = (workspaceGrants?.grants ?? []).filter((g) => !objectTypes.includes(g.objectType)).map((g) => g.objectType)
450
+ if (unknownTypes.length) result = { ...result, output: `${result.output}\n warning: blueprint/workspace-grants.yaml names ${unknownTypes.join(', ')}, which the project's Blueprint doesn't hold here` }
451
+ for (const step of followUpsFor(target, profiles, { publish: opts.publish, workspaceId: opts.workspaceId, objectTypes, grants: workspaceGrants?.grants ?? [] })) {
283
452
  const done = await runCli(project, step.args, opts.auth)
284
453
  if (!done.ok && !step.alreadyDone?.test(done.output)) return done
285
454
  }
286
455
  if (blueprint) {
287
456
  // After the publish: what writes only the Blueprint's records runs here; the rest stays switched off.
288
457
  const actions: Record<string, ActionBuild> = actionCalls ? await deployActions(actionCalls, blueprint.actions) : {}
289
- for (const [name, why] of Object.entries(blueprint.leftOffActions)) actions[name] = { status: 'left-off', reason: why }
458
+ for (const [name, why] of Object.entries(blueprint.leftOffActions)) actions[name] = { status: 'left-off', reason: why, ...(blueprint.refusedActions?.includes(name) ? { refused: true } : {}) }
459
+ // Deployed as the workspace runs it, with what its owner fixes there: the apply said it (a `warning:`
460
+ // line naming the file), and it is kept on its build for `local status` and `local check`.
461
+ for (const [name, lines] of Object.entries(blueprint.inheritedWarnings ?? {})) {
462
+ const a = actions[name]
463
+ if (a && a.status !== 'left-off') actions[name] = { ...a, inherited: lines }
464
+ }
290
465
  for (const [name, a] of Object.entries(actions)) {
291
466
  if (a.status === 'runs') result = { ...result, output: `${result.output}\n governed Action ${name} runs on this runtime` }
292
- else if (a.status !== 'left-off') result = { ...result, output: `${result.output}\n warning: governed Action ${name} ${a.status === 'failed' ? 'didn’t deploy' : 'is switched off here'}: ${a.reason}` }
467
+ // A failed deploy is not a warning: it fails the Blueprint below, so nothing later meets it as ACTION_FORBIDDEN first.
468
+ else if (a.status === 'switched-off') result = { ...result, output: `${result.output}\n warning: governed Action ${name} is switched off here: ${a.reason}` }
293
469
  }
294
470
  // Saved object sets, once the object types they read are published here.
295
471
  let objectSets: string[] = []
296
472
  let objectSetsAgain: string[] = []
473
+ let objectSetIds: Record<string, string> = {}
297
474
  if (creds && opts.publish) {
298
475
  const leftOff = new Set(Object.entries(blueprint.objects).flatMap(([name, o]) => (o.status === 'left-off' ? [name] : [])))
299
476
  const sets = await applyObjectSets(project.root, objectSetCalls(opts.serviceUrl, creds), leftOff)
300
477
  if (sets.lines.length || sets.warnings.length) result = { ...result, output: [result.output, ...sets.lines, ...sets.warnings].join('\n').trim() }
301
478
  objectSets = sets.warnings.map(adaptationOf)
302
479
  objectSetsAgain = sets.recreated
480
+ objectSetIds = sets.ids
303
481
  }
304
482
  result = {
305
483
  ...result,
306
- blueprint: { objects: blueprint.objects, actions, ...(objectSets.length ? { objectSets } : {}) },
484
+ blueprint: { objects: blueprint.objects, actions, ...(objectSets.length ? { objectSets } : {}), ...(Object.keys(objectSetIds).length ? { objectSetIds } : {}), ...(blueprint.parts ? { parts: blueprint.parts } : {}) },
307
485
  ...(authoredAgain.length ? { authoredAgain } : {}),
308
486
  ...(objectSetsAgain.length ? { objectSetsAgain } : {}),
309
487
  }
488
+ // A governed Action that should run here and did not deploy fails the Blueprint: the object
489
+ // types are published, but what names the Action would only meet ACTION_FORBIDDEN later.
490
+ const undeployed = actionFailureLines(actions, project.root)
491
+ if (undeployed.length) {
492
+ const n = Object.values(actions).filter((a) => a.status === 'failed').length
493
+ const failureLines = [`error [LOCAL_ACTION_NOT_DEPLOYED]: ${n} governed Action${n === 1 ? '' : 's'} didn’t deploy on the local runtime`, ...undeployed]
494
+ result = { ...result, ok: false, failureLines, output: [...failureLines, result.output].join('\n').trim() }
495
+ }
310
496
  }
311
497
  return result
312
498
  }
313
499
 
500
+ /**
501
+ * A Function's build output, led by what it imports that the project lacks:
502
+ * a relative import the bundler could not resolve, named by its project path
503
+ * (`functions/shared/eligibility`). A pull writes such a file only when the
504
+ * Function's source in the workspace holds it; one deployed before deploys
505
+ * kept them does not, so the next step is said. The output as it is when no
506
+ * import is unresolved.
507
+ */
508
+ export function missingImportsOutput(project: LocalProject, entry: string, output: string): string {
509
+ const specs = [...new Set([...output.matchAll(/Could not resolve: "(\.{1,2}\/[^"]+)"/g)].map((m) => m[1]!))]
510
+ if (!specs.length) return output
511
+ const from = entry.split(sep).join('/')
512
+ const at = from.startsWith('.frontera/local/resolved/') ? from.slice('.frontera/local/resolved/'.length) : from
513
+ const paths = specs.map((s) => posix.normalize(posix.join(posix.dirname(at), s))).filter((p) => !existsSync(join(project.root, p)) && !['.ts', '.tsx', '.js', '/index.ts'].some((ext) => existsSync(join(project.root, `${p}${ext}`))))
514
+ if (!paths.length) return output
515
+ const named = paths.length === 1 ? paths[0]! : `${paths.slice(0, -1).join(', ')} and ${paths[paths.length - 1]}`
516
+ return [
517
+ `error [FUNCTION_IMPORT_MISSING]: imports ${named}, which ${paths.length === 1 ? 'isn’t' : 'aren’t'} in the project (from ${at})`,
518
+ ` hint: a pull brings what a Function imports from beside its folder when its source in the workspace holds it. If it doesn’t (deployed before deploys kept those files), deploy the Function again from its source with a current CLI and pull it, or add ${paths.length === 1 ? 'the file' : 'the files'} here`,
519
+ output,
520
+ ].join('\n')
521
+ }
522
+
523
+ /**
524
+ * An Agent's file without the Plugins the runtime refused (`invalid_plugins:
525
+ * decision`), as a copy under `.frontera/local/resolved/`: the names and the
526
+ * copy's path, or null when the refusal is not that, or names none the file has.
527
+ */
528
+ export function withoutPlugins(project: LocalProject, file: string, output: string): { names: string[]; file: string } | null {
529
+ const m = /invalid_plugins: ([^\n]+)/.exec(output)
530
+ if (!m) return null
531
+ const refused = new Set(m[1]!.split(',').map((n) => n.trim()).filter(Boolean))
532
+ let doc: { config?: { plugins?: unknown } }
533
+ try {
534
+ doc = JSON.parse(readFileSync(join(project.root, file), 'utf8'))
535
+ } catch {
536
+ return null
537
+ }
538
+ const plugins = Array.isArray(doc.config?.plugins) ? (doc.config!.plugins as unknown[]) : []
539
+ const names = plugins.filter((p): p is string => typeof p === 'string' && refused.has(p))
540
+ if (!names.length) return null
541
+ doc.config!.plugins = plugins.filter((p) => !(typeof p === 'string' && refused.has(p)))
542
+ const copy = file.startsWith(join('.frontera', 'local', 'resolved')) ? file : join('.frontera', 'local', 'resolved', file)
543
+ mkdirSync(join(project.root, copy, '..'), { recursive: true })
544
+ writeFileSync(join(project.root, copy), `${JSON.stringify(doc, null, 2)}\n`)
545
+ return { names, file: copy }
546
+ }
547
+
548
+ /** A warning the workflow service's validation gave about a step (`node "ask": …`) or the output (`output: …`), as `workflow push` prints it. */
549
+ export function isValidationWarning(line: string): boolean {
550
+ return /^\s*warning: (node "[^"]+"|output):/.test(line)
551
+ }
552
+
553
+ /**
554
+ * The lines of an apply's output `local dev` says: each warning, and each
555
+ * promotion of a workflow. Indented or not: an output put together from
556
+ * several commands is trimmed, which takes the indent off its first line (a
557
+ * push's first validation warning, before its "pushed draft" line).
558
+ */
559
+ export function saidLines(output: string): string[] {
560
+ // `note:` says what an apply did that is not an adaptation (documents being chunked again).
561
+ return output.split('\n').filter((line) => /^\s*(warning: |note: |promoted workflow )/.test(line)).map((line) => ` ${line.trim()}`)
562
+ }
563
+
564
+ /** `hello: pushed draft v3` → 3: the version a `workflow push` stored. */
565
+ export function pushedVersionOf(output: string): number | undefined {
566
+ const m = /:\s+pushed draft v(\d+)\b/.exec(output)
567
+ return m ? Number(m[1]) : undefined
568
+ }
569
+
570
+ /**
571
+ * Bind every object type that reads a dataset to the dataset's current
572
+ * revision, with the mapping in its file: after `dataset revise`, the types
573
+ * still read the revision their binding was made on. The binding's tree write
574
+ * goes to a folder that holds no object types, so the project's files stay
575
+ * as they are.
576
+ */
577
+ async function rebindReaders(project: LocalProject, dataset: string, profiles: SyncProfiles, run: (args: string[]) => Promise<RunResult>): Promise<RunResult> {
578
+ const dir = join(project.root, 'blueprint', 'object-types')
579
+ const scratch = join(project.root, '.frontera', 'local', 'resolved', 'rebind')
580
+ const lines: string[] = []
581
+ let names: string[] = []
582
+ try {
583
+ names = readdirSync(dir).filter((f) => /\.(ya?ml|json)$/.test(f) && !f.startsWith('.'))
584
+ } catch {
585
+ return { ok: true, output: '' }
586
+ }
587
+ for (const file of names.sort()) {
588
+ let doc: { apiName?: unknown; backing?: { dataset?: unknown; mapping?: unknown } } | null
589
+ try {
590
+ doc = parseYaml(readFileSync(join(dir, file), 'utf8')) as typeof doc
591
+ } catch {
592
+ continue
593
+ }
594
+ if (typeof doc?.apiName !== 'string' || doc.backing?.dataset !== dataset || !doc.backing.mapping || typeof doc.backing.mapping !== 'object') continue
595
+ mkdirSync(scratch, { recursive: true })
596
+ const mappingFile = join(scratch, `${doc.apiName}.json`)
597
+ writeFileSync(mappingFile, `${JSON.stringify({ mapping: doc.backing.mapping }, null, 2)}\n`)
598
+ const bound = await run(['blueprint', 'bind', doc.apiName, '--dataset', dataset, '--file', mappingFile, '--accept-contract-change', '--dir', join(scratch, 'tree'), '--profile', profiles.organization])
599
+ if (!bound.ok) return { ...bound, output: `error [BLUEPRINT_REBIND]: ${doc.apiName} could not be bound to the new revision of ${dataset}\n${bound.output.trim()}` }
600
+ lines.push(` bound ${doc.apiName} to the new revision of ${dataset}`)
601
+ }
602
+ return { ok: true, output: lines.join('\n') }
603
+ }
604
+
605
+ /** The local runner's loopback address, to ask it to sync a deploy at once. */
606
+ function runnerAt(project: LocalProject): { runnerUrl?: string } {
607
+ const port = readState(project)?.ports.runner
608
+ return port ? { runnerUrl: `http://127.0.0.1:${port}` } : {}
609
+ }
610
+
611
+ /** The governed Actions the project's Blueprint deploys here that the runtime does not run, read live. */
612
+ async function unchangedActionsDown(project: LocalProject, serviceUrl: string, creds: LocalCredentials, organizationId: string): Promise<Array<{ apiName: string; why: string }>> {
613
+ // An Action the runtime refused from these same files stays off however often the Blueprint is
614
+ // applied again: only a change to the files can bring it up, so it is not a reason to apply.
615
+ const recorded = loadBuild(project, organizationId).blueprint
616
+ const sameFiles = recorded?.hash !== undefined && recorded.hash === contentHash(project, { kind: 'blueprint', key: 'blueprint' })
617
+ const refusedHere = new Set(sameFiles ? Object.entries(recorded?.actions ?? {}).filter(([, a]) => a.status === 'left-off' || a.status === 'failed').map(([name]) => name) : [])
618
+ const expected = resolveBlueprint(project).actions.filter((a) => a.fit.runsLocally && !refusedHere.has(a.apiName)).map((a) => a.apiName)
619
+ if (!expected.length) return []
620
+ try {
621
+ return actionsNotRunning(expected, await liveActionStates(localActionCalls(serviceUrl, creds)))
622
+ } catch {
623
+ // Not readable now: the files decide, as before.
624
+ return []
625
+ }
626
+ }
627
+
628
+ /** The checks on an Agent's own files before they are applied: errors refuse the file, warnings travel with it. */
629
+ function agentChecks(project: LocalProject, target: SyncTarget): { errors: string[]; warnings: string[] } {
630
+ if (target.kind !== 'agent' && target.kind !== 'operating' && target.kind !== 'bindings') return { errors: [], warnings: [] }
631
+ let doc: unknown
632
+ try {
633
+ doc = JSON.parse(readFileSync(join(project.root, target.file), 'utf8'))
634
+ } catch {
635
+ // Unreadable: the apply says so with the file's own error.
636
+ return { errors: [], warnings: [] }
637
+ }
638
+ if (target.kind === 'agent') return { errors: agentFileErrors(doc, target.slug), warnings: agentFileWarnings(doc, target.slug) }
639
+ if (target.kind === 'bindings') return { errors: [], warnings: approvalBindingNotes(project.root, target.slug, doc) }
640
+ return procedureToolProblems(doc, target.slug, boundTools(project.root, target.slug))
641
+ }
642
+
643
+ /**
644
+ * A failed Blueprint's output against the project's files: each indexed path
645
+ * (`actions.1.concurrency.strategy`) named by its file, read from the draft the
646
+ * runtime holds, and each issue said once.
647
+ */
648
+ async function blueprintFailureOutput(project: LocalProject, output: string, serviceUrl: string): Promise<string> {
649
+ let text = output
650
+ const creds = readState(project)?.credentials
651
+ if (creds && hasIndexedPaths(text)) {
652
+ try {
653
+ const lifecycle = await localActionCalls(serviceUrl, creds)('org', 'GET', '/v1/blueprint/lifecycle')
654
+ const body = lifecycle.body as { data?: { draft?: { definition?: unknown } }; draft?: { definition?: unknown } } | null
655
+ const definition = body?.data?.draft?.definition ?? body?.draft?.definition
656
+ if (lifecycle.status < 300 && definition) text = nameIndexedPaths(text, definition, project.root)
657
+ } catch {
658
+ // Unnamed paths are still the runtime's words.
659
+ }
660
+ }
661
+ const lines = dedupeLines(text.split('\n'))
662
+ // Source systems have no file: say the one name an Action on the Blueprint's records gives.
663
+ const hint = sourceSystemHint(text)
664
+ if (hint && !text.includes(hint)) lines.push(`hint: ${hint}`)
665
+ return lines.join('\n')
666
+ }
667
+
314
668
  /**
315
669
  * What a target was applied with, in words: the warnings the apply printed
316
670
  * (a Plugin it went without, a model swapped) and what it needs that the
317
671
  * runtime gives it differently (no computer; a governed Action switched off
318
672
  * here, as the last Blueprint build left it).
319
673
  */
320
- function adaptationsFor(project: LocalProject, target: SyncTarget, output: string, organizationId: string): { adaptations: string[]; stubbed: boolean } {
321
- const fromOutput = output.split('\n').filter((l) => /^\s*warning: /.test(l)).map(adaptationOf)
674
+ function adaptationsFor(project: LocalProject, target: SyncTarget, output: string, organizationId: string, validationWarnings: string[] = []): { adaptations: string[]; stubbed: boolean } {
675
+ // A workflow's validation warnings (`node "ask": …`, from the push) and the platform's findings on
676
+ // an Agent's behavior settings are said, but they hold wherever it runs: not adaptations to this runtime.
677
+ const validation = new Set(validationWarnings.map((l) => l.trim()))
678
+ // An Action's warning inherited from the workspace holds there too: the Action's, not an adaptation here.
679
+ const fromOutput = output.split('\n').filter((l) => /^\s*warning: /.test(l) && !(target.kind === 'workflow' && isValidationWarning(l)) && !validation.has(l.trim()) && !isApprovalNote(l) && !l.includes(INHERITED_FROM_PULL)).map(adaptationOf)
322
680
  if (target.kind === 'blueprint' || target.kind === 'dataset') return { adaptations: [...new Set(fromOutput)], stubbed: false }
323
681
  let files: Record<string, string> = {}
324
682
  try {
@@ -340,11 +698,20 @@ function adaptationsFor(project: LocalProject, target: SyncTarget, output: strin
340
698
  ...[...platformOnly].map((n) => [n, 'analytics'] as const),
341
699
  ])
342
700
  const portability = classify(itemNeeds(kind, files, new Set([...standIns, ...platformOnly]), builtIns), { actions, titles })
343
- const judged = portability.adaptations.map((a) => a.charAt(0).toUpperCase() + a.slice(1))
701
+ // A Chat App that published here (its Agents were published) is no longer the owner's draft.
702
+ const published = target.kind === 'chat-app' && /^\s+published Chat App /m.test(output)
703
+ const judged = portability.adaptations
704
+ .map((a) => (a.charAt(0).toUpperCase() + a.slice(1)).replace('/apps/<slug>', target.kind === 'chat-app' ? `/apps/${target.name}` : '/apps/<slug>'))
705
+ .filter((a) => !(published && /^Saved as the owner’s draft/.test(a)))
344
706
  const stubbed = portability.class === 'stubbed' || (target.kind === 'plugin' && target.file.endsWith('.stand-in.json') && readStandIn(project.root, target.name)?.serves !== 'documentation')
345
707
  return { adaptations: [...new Set([...judged, ...fromOutput])], stubbed }
346
708
  }
347
709
 
710
+ /** What the build record keeps of a Blueprint apply: each type, Action and left-off part, and the saved sets' ids. */
711
+ function blueprintRecord(b: NonNullable<RunResult['blueprint']>): Pick<BuildEntry, 'objects' | 'actions' | 'objectSetIds' | 'parts'> {
712
+ return { objects: b.objects, actions: b.actions, ...(b.objectSetIds ? { objectSetIds: b.objectSetIds } : {}), ...(b.parts ? { parts: b.parts } : {}) }
713
+ }
714
+
348
715
  /** The Blueprint's own adaptations, one line each: what is left off, and the Actions that do not run here. */
349
716
  function blueprintAdaptations(b: NonNullable<RunResult['blueprint']>): string[] {
350
717
  const out: string[] = []
@@ -355,10 +722,34 @@ function blueprintAdaptations(b: NonNullable<RunResult['blueprint']>): string[]
355
722
  if (a.status === 'failed') out.push(`Governed Action ${name} didn’t deploy: ${a.reason}`)
356
723
  if (a.status === 'left-off') out.push(`Governed Action ${name} is left off: ${a.reason}`)
357
724
  }
725
+ for (const [ref, p] of Object.entries(b.parts ?? {})) out.push(`${ref.replace('/', ' ').replace(/^linkType /, 'Link type ').replace(/^metric /, 'Metric ').replace(/^semanticType /, 'Semantic type ').replace(/^sharedField /, 'Shared field ')} is left off: ${p.reason}`)
358
726
  out.push(...(b.objectSets ?? []))
359
727
  return out
360
728
  }
361
729
 
730
+ /**
731
+ * Sample data an older generator made (`remakeReason`), made again before the
732
+ * first apply, so each dataset applies once, as made now. The lines to say;
733
+ * none when there is nothing to remake, or `--keep-sample-data`. A pulled
734
+ * project's sample data that the pull wrote (`pulledSampleData`) is not made
735
+ * again unasked: a tracked file a first apply changes is a change nobody
736
+ * made. One line says newer rules exist, with the command that makes it
737
+ * again (`--remake-sample-data`, `asked`).
738
+ */
739
+ export async function remakeStaleSampleData(root: string, keep: boolean, asked = false): Promise<string[]> {
740
+ if (keep) return []
741
+ const why = remakeReason(root, false)
742
+ if (!why) return []
743
+ if (!asked && pulledSampleData(root)) return hasGeneratedSampleData(root) ? [pulledSampleDataNote(root)] : []
744
+ try {
745
+ const remade = await remakeSampleData(root, why)
746
+ return remade ? remakeLines(remade) : []
747
+ } catch (err) {
748
+ // The datasets apply as they are; the remake is said, never a reason to stop.
749
+ return [` warning: sample data wasn’t made again (${why}): ${(err as Error).message}`]
750
+ }
751
+ }
752
+
362
753
  /**
363
754
  * The bindings an Agent could not get because the Function, workflow or
364
755
  * object type (its Context and data) is not on the runtime yet.
@@ -367,12 +758,22 @@ export function missingFromBindings(output: string): string[] {
367
758
  return [...new Set([...output.matchAll(/^\s+((?:function|workflow|objectType)\/[A-Za-z0-9._-]+): (?:404|.*(?:not found|no live version|does not exist|not on the runtime yet))/gim)].map((m) => m[1]!))]
368
759
  }
369
760
 
761
+ /** How the runtime's embeddings stood against the workspace's, in words, for the line that applies a knowledge base again. */
762
+ function embeddingWords(e: NonNullable<BuildEntry['embeddings']>): string {
763
+ return e === 'keyless' ? 'the keyless local embeddings' : e === 'same' ? 'the workspace’s model' : e === 'workspace' ? 'another model than the workspace’s' : 'another model than the platform’s default'
764
+ }
765
+
370
766
  async function dev(ctx: CommandContext) {
767
+ // `--strict` decides what one apply exits with; a watcher has no exit to decide.
768
+ if (flagBool(ctx, 'strict') && !flagBool(ctx, 'once')) throw new UsageError('--strict needs --once', 'frontera local dev --once --strict')
371
769
  const project = localProject(ctx.cwd)
372
770
  const state = readState(project)
373
771
  if (!state || state.phase !== 'ready' || !state.supervisorPid || !isAlive(state.supervisorPid)) {
374
772
  throw new CliError('the local runtime is not running', { code: 'LOCAL_NOT_RUNNING', hint: 'run `frontera local start` first' })
375
773
  }
774
+ // A copy of a project carries the binding it was made under: bound here to this runtime instead (`copiedLocalBinding`).
775
+ const rebound = rebindCopiedProject(project)
776
+ if (rebound) ctx.output.note(rebound)
376
777
  // The profile was registered when the runtime started; applying needs only the keys in the project's state.
377
778
  const workspace = localProfileName(project)
378
779
  const profiles: SyncProfiles = { workspace, organization: `${workspace}-org`.slice(0, 64) }
@@ -387,6 +788,7 @@ async function dev(ctx: CommandContext) {
387
788
  publish: !flagBool(ctx, 'no-publish'),
388
789
  workspaceId: state.credentials!.workspaceId,
389
790
  serviceUrl,
791
+ note: (line) => ctx.output.note(line),
390
792
  }
391
793
 
392
794
  const organizationId = state.credentials!.organizationId
@@ -402,7 +804,7 @@ async function dev(ctx: CommandContext) {
402
804
  const seen = JSON.stringify(synced)
403
805
  if (seen === settingsSeen) return
404
806
  settingsSeen = seen
405
- const mirrored = await mirrorOrgSettings(synced, settingsCalls(serviceUrl, creds)).catch(() => null)
807
+ const mirrored = await mirrorOrgSettings(synced, settingsCalls(serviceUrl, creds), { localEmbeddings: localEmbeddingsReachable() }).catch(() => null)
406
808
  if (mirrored) {
407
809
  for (const line of mirrored.lines) ctx.output.note(line)
408
810
  for (const d of mirrored.differences) ctx.output.note(` differs from the workspace: ${d}`)
@@ -418,36 +820,82 @@ async function dev(ctx: CommandContext) {
418
820
  const promoteAfter = new Set<string>()
419
821
  // Agents whose row name was checked against their file in this session.
420
822
  const named = new Set<string>()
421
- let failures = 0
823
+ // What is failing now, by target, for `--once --json`: the item and its error.
824
+ const failing = new Map<string, { item: string; error: string }>()
825
+ // What each item's last apply warned, by target, for `--once --json` (and the MCP `apply`): the item and its lines.
826
+ const warned = new Map<string, { item: string; warnings: string[] }>()
422
827
  // Whether anything applied since the failed targets were last tried.
423
828
  let progressed = false
424
829
  let busy = false
425
830
  let stopping = false
426
831
  let timer: ReturnType<typeof setTimeout> | null = null
832
+ // An item applied again and again with no change to its files is a bug: stopped, and said (`ApplyWatchdog`).
833
+ const watchdog = new ApplyWatchdog()
834
+ let drains = 0
835
+ // Items that failed because the runtime wasn't answering: applied again once it answers.
836
+ const waiting = new Map<string, SyncTarget>()
837
+ // Set by an apply that found the runtime down: the drain stops, and what is queued waits for it.
838
+ let runtimeDown = false
839
+ // What starts the next round of applies, for Activity: the files queued since the last one, or a reason of its own.
840
+ const changedFiles = new Set<string>()
841
+ let nextTrigger: string | null = null
842
+ /**
843
+ * The round under way, once it applies something: a round that finds every
844
+ * queued item unchanged applies nothing, so it never says "Applying" and is
845
+ * never recorded as an apply.
846
+ */
847
+ let round: { since: string | null; trigger: string; applied: number; failed: number } = { since: null, trigger: '', applied: 0, failed: 0 }
848
+ const markApplying = () => {
849
+ if (round.since) return
850
+ round.since = new Date().toISOString()
851
+ // Forge and Studio say "Applying…" until the queue is done, not a half-made count as the result.
852
+ recordApplying(project, { since: round.since, queued: pending.size + 1, pid: process.pid, trigger: round.trigger })
853
+ }
427
854
 
428
855
  /** What one apply came to, for the build record Forge and Studio read (design §9). */
429
856
  const buildEntry = (target: SyncTarget, hash: string | null, result: RunResult): BuildEntry => {
430
- const base = { what: describeTarget(target), hash, at: new Date().toISOString() }
857
+ // A workflow: the version its last promotion here made live, this apply's or the one before.
858
+ const live = target.kind === 'workflow' ? (result.ok ? promotedVersionOf(result.output, target.slug) : undefined) ?? loadBuild(project, organizationId)[target.key]?.live : undefined
859
+ const base = { what: describeTarget(target), hash, at: new Date().toISOString(), ...(result.ok && result.version ? { version: result.version } : {}), ...(live ? { live } : {}) }
431
860
  if (result.ok) {
432
861
  // A Blueprint with a media-backed type over an empty Media Set is Stubbed, as an item with a stand-in is.
433
- const { adaptations, stubbed } = result.blueprint ? { adaptations: [...new Set([...blueprintAdaptations(result.blueprint)])], stubbed: Object.values(result.blueprint.objects).some((o) => o.status === 'stubbed') } : adaptationsFor(project, target, result.output, organizationId)
862
+ const { adaptations, stubbed } = result.blueprint ? { adaptations: [...new Set([...blueprintAdaptations(result.blueprint)])], stubbed: Object.values(result.blueprint.objects).some((o) => o.status === 'stubbed') } : adaptationsFor(project, target, result.output, organizationId, result.validationWarnings)
434
863
  return {
435
864
  ...base,
436
865
  status: stubbed ? 'stubbed' : adaptations.length ? 'adapted' : 'built',
437
866
  ...(adaptations.length ? { adaptations } : {}),
438
- ...(result.blueprint ? { objects: result.blueprint.objects, actions: result.blueprint.actions } : {}),
867
+ ...(result.blueprint ? blueprintRecord(result.blueprint) : {}),
439
868
  }
440
869
  }
441
870
  const missing = result.missing?.length ? result.missing : target.kind === 'bindings' ? missingFromBindings(result.output) : []
442
871
  if (missing.length || result.unnamedActions) return { ...base, status: 'blocked', needs: missing, ...(result.unnamedActions ? { unnamedActions: result.unnamedActions } : {}) }
443
- return { ...base, status: 'failed', error: oneLine(applyErrorText(result.output)) }
872
+ // A Blueprint published with an Action that didn't deploy keeps how each type and Action came out.
873
+ return { ...base, status: 'failed', error: oneLine(applyErrorText(result.output)), ...failureCodeAndHint(result.output), ...(result.blueprint ? blueprintRecord(result.blueprint) : {}) }
444
874
  }
445
875
 
446
876
  const runOne = async (target: SyncTarget) => {
447
877
  if (stopping) return
448
878
  if (target.kind !== 'blueprint' && !existsSync(join(project.root, target.file))) return
449
879
  const hash = contentHash(project, target)
450
- if (hash && applied.get(target.key) === hash) {
880
+ // Unchanged files, but the runtime lost the Agent's draft (it was published): the draft is
881
+ // what its test cases and Studio's Draft chat run, so it is staged again from the files.
882
+ const draftGone = hash && applied.get(target.key) === hash && target.kind === 'agent' && (await hasAgentDraft({ slug: target.slug, serviceUrl, creds })) === false
883
+ if (draftGone) ctx.output.note(` ${describeTarget(target)} has no draft on the runtime (it was published): staging it again from ${target.file}`)
884
+ // Unchanged files, but a governed Action that should run here does not (a failed apply left it
885
+ // switched off, or its deployment no longer matches its contract): the Blueprint is applied again.
886
+ const actionsDown = hash && applied.get(target.key) === hash && target.kind === 'blueprint' && options.publish ? await unchangedActionsDown(project, serviceUrl, creds, organizationId) : []
887
+ for (const a of actionsDown) ctx.output.note(` governed Action ${a.apiName} doesn’t run on the runtime (${a.why}): applying the Blueprint again`)
888
+ // Unchanged, but applied before the build recorded which version the file was pushed as:
889
+ // pushed once more, so its tests run exactly that version (never a stale draft).
890
+ const recorded = loadBuild(project, organizationId)[target.key]
891
+ const versionUnknown = hash && applied.get(target.key) === hash && target.kind === 'workflow' && (!recorded || (recorded.status !== 'failed' && recorded.status !== 'blocked' && recorded.version === undefined))
892
+ if (versionUnknown) ctx.output.note(` ${describeTarget(target)}: pushing ${target.file} again to record the version it runs as`)
893
+ // A knowledge base's threshold follows the runtime's embeddings (`knowledgeSettings`): when they changed since
894
+ // it was applied (a key set, the keyless local embeddings in its place), it is applied again with unchanged files.
895
+ const embeddings = target.kind === 'knowledge' ? ((await embeddingsDiffer(project.root, { serviceUrl, creds }).catch((): false => false)) || ('same' as const)) : undefined
896
+ const embeddingsMoved = !!embeddings && !!hash && applied.get(target.key) === hash && (recorded?.embeddings ?? 'same') !== embeddings
897
+ if (embeddingsMoved) ctx.output.note(` ${describeTarget(target)}: the runtime’s embeddings changed since it was applied (${embeddingWords(recorded?.embeddings ?? 'same')} then, ${embeddingWords(embeddings!)} now): applying it again so its threshold follows`)
898
+ if (hash && applied.get(target.key) === hash && !draftGone && !actionsDown.length && !versionUnknown && !embeddingsMoved) {
451
899
  unchanged += 1
452
900
  // An Agent applied before names were set from its file still reads its slug: set it once.
453
901
  if (target.kind === 'agent' && !named.has(target.slug)) {
@@ -461,26 +909,89 @@ async function dev(ctx: CommandContext) {
461
909
  if (!loadBuild(project, organizationId)[target.key]) recordBuild(project, organizationId, target.key, { what: describeTarget(target), status: 'built', hash, at: new Date().toISOString() })
462
910
  return
463
911
  }
912
+ const verdict = watchdog.before(target.key, hash, drains)
913
+ if (verdict !== 'apply') {
914
+ if (verdict === 'stop') {
915
+ const line = stoppedReapplyingLine(describeTarget(target), watchdog.applyLimit)
916
+ ctx.output.note(` ✕ ${line}`)
917
+ recordBuild(project, organizationId, target.key, { what: describeTarget(target), status: 'blocked', needs: [], error: line, hash, at: new Date().toISOString() })
918
+ failing.set(target.key, { item: describeTarget(target), error: line })
919
+ }
920
+ return
921
+ }
464
922
  const started = Date.now()
465
- const result = await apply(project, target, options)
923
+ markApplying()
924
+ let result = await apply(project, target, options)
925
+ watchdog.after(target.key, result.ok)
926
+ if (result.ok) round.applied += 1
927
+ else round.failed += 1
466
928
  if (stopping) return
929
+ // A failure while the runtime wasn't answering (stopped, restarting, its database going down
930
+ // or being restored) is not the files': said as that, and applied again when it answers.
931
+ if (!result.ok && (await failedForRuntime(project, result.output))) {
932
+ runtimeDown = true
933
+ if (applied.delete(target.key)) saveHashes(project, organizationId, applied)
934
+ waiting.set(target.key, target)
935
+ failing.set(target.key, { item: describeTarget(target), error: RUNTIME_DOWN_ERROR })
936
+ recordBuild(project, organizationId, target.key, { what: describeTarget(target), status: 'failed', error: RUNTIME_DOWN_ERROR, hash, at: new Date().toISOString() })
937
+ ctx.output.note(` ✕ ${describeTarget(target)}: ${RUNTIME_STOPPED_LINE}`)
938
+ return
939
+ }
940
+ if (!result.ok && target.kind === 'blueprint') result = { ...result, output: await blueprintFailureOutput(project, result.output, serviceUrl) }
467
941
  const ms = Date.now() - started
468
942
  recordApplied(project, { at: new Date().toISOString(), what: describeTarget(target), ok: result.ok })
469
- recordBuild(project, organizationId, target.key, buildEntry(target, hash, result))
943
+ recordBuild(project, organizationId, target.key, { ...buildEntry(target, hash, result), ...(embeddings ? { embeddings } : {}) })
470
944
  if (result.ok) {
471
945
  // A draft-only Blueprint is not what a plain `local dev` would leave, so it is not recorded as applied.
472
946
  if (target.kind === 'blueprint' && !options.publish) applied.delete(target.key)
473
947
  else applied.set(target.key, hash)
474
948
  saveHashes(project, organizationId, applied)
475
949
  failed.delete(target.key)
950
+ failing.delete(target.key)
476
951
  ctx.output.note(` ✓ ${describeTarget(target)} applied (${ms} ms)`)
952
+ if (target.kind === 'blueprint') personasDue = true
477
953
  if (target.kind === 'workflow' && promoteAfter.delete(target.key)) {
478
- const promoted = await promoteWorkflow(serviceUrl, creds, target.slug)
479
- if (promoted) ctx.output.note(` promoted workflow ${target.slug} v${promoted}: its live version ran the Action's old id`)
954
+ const promoted = await promoteWorkflow(serviceUrl, creds, target.slug, result.version)
955
+ if (promoted) {
956
+ ctx.output.note(` promoted workflow ${target.slug} v${promoted}: its live version ran the Action's old id`)
957
+ const entry = loadBuild(project, organizationId)[target.key]
958
+ if (entry) recordBuild(project, organizationId, target.key, { ...entry, live: promoted })
959
+ }
480
960
  }
481
961
  // What it was applied without, or with instead (a missing model), said once here.
482
- for (const line of result.output.split('\n')) if (/^\s+warning: /.test(line)) ctx.output.note(` ${line.trim()}`)
962
+ // A promotion is said too: what Agents and webhooks start changed.
963
+ const warnings = saidLines(result.output).filter((line) => /^\s*warning: /.test(line)).map((line) => line.trim().replace(/^warning: /, ''))
964
+ if (warnings.length) warned.set(target.key, { item: describeTarget(target), warnings })
965
+ else warned.delete(target.key)
966
+ for (const line of saidLines(result.output)) {
967
+ ctx.output.note(line)
968
+ const secret = /Needs a value for ([A-Z][A-Z0-9_]*)/.exec(line)?.[1]
969
+ if (secret) secretsSaid.add(secret)
970
+ }
971
+ if (result.datasetRevised) {
972
+ ctx.output.note(result.output.trim().replace(/^/, ' '))
973
+ // The object types reading it move to the new revision when the Blueprint is applied again.
974
+ applied.delete('blueprint')
975
+ pending.set('blueprint', { kind: 'blueprint', key: 'blueprint' })
976
+ }
483
977
  progressed = true
978
+ // An Agent applied without this Function while it did not build gets it now.
979
+ const built = target.kind === 'function' ? functionSlugOf(target.key) : null
980
+ if (built) {
981
+ const record = loadBuild(project, organizationId)
982
+ for (const t of allTargets(project.root)) {
983
+ if (t.kind !== 'bindings' || record[t.key]?.status === 'built' || pending.has(t.key)) continue
984
+ let text = ''
985
+ try {
986
+ text = readFileSync(join(project.root, t.file), 'utf8')
987
+ } catch {
988
+ continue
989
+ }
990
+ if (!text.includes(`function/${built}"`)) continue
991
+ applied.delete(t.key)
992
+ pending.set(t.key, t)
993
+ }
994
+ }
484
995
  // Workflows and Agents hold the runtime's id of each Action they run: a new id means applying them again.
485
996
  if (result.authoredAgain?.length || result.objectSetsAgain?.length) {
486
997
  const names = result.authoredAgain ?? []
@@ -500,17 +1011,65 @@ async function dev(ctx: CommandContext) {
500
1011
  }
501
1012
  }
502
1013
  } else {
1014
+ // A failed apply may have changed the runtime (an Action retired, switched off): the file is not
1015
+ // "unchanged" afterwards, even when it is restored to what last applied.
1016
+ if (applied.delete(target.key)) saveHashes(project, organizationId, applied)
503
1017
  failed.set(target.key, target)
504
- // A retry that fails the same way is not said (or counted) again in the same round of changes.
505
- const text = applyErrorText(result.output)
1018
+ // A retry that fails the same way is not said again in the same round of changes.
1019
+ // A governed Action's checks are each a line to fix: shown whole, not cut to the first seven lines.
1020
+ const text = result.failureLines ? result.failureLines.map((l, i) => (i ? ` ${l}` : l)).join('\n') : applyErrorText(result.output)
1021
+ failing.set(target.key, { item: describeTarget(target), error: result.failureLines?.join('\n') ?? applyErrorText(result.output).replace(/\n\s+/g, '\n') })
506
1022
  if (said.get(target.key) !== text) {
507
- failures += 1
508
1023
  said.set(target.key, text)
509
1024
  ctx.output.note(` ✕ ${describeTarget(target)}: ${text}`)
510
1025
  }
511
1026
  }
512
1027
  }
513
1028
 
1029
+ /** Whether the local personas are to be seeded after this drain: at the start, and after each Blueprint apply. */
1030
+ let personasDue = true
1031
+ /** Whether the project's secrets are to be said after this drain: after a database restore. */
1032
+ let secretsDue = false
1033
+ /** Whether to deploy again the Custom Apps a database restore took away (`local-app-restore.ts`): at the start, and after a restore. */
1034
+ let appsDue = true
1035
+
1036
+ /**
1037
+ * A Custom App deployed after the baseline a restore went back to is not on
1038
+ * the runtime: deployed again from its folder (`frontera app deploy
1039
+ * --build`), as it was. A failure keeps the mark: status says so, with the fix.
1040
+ */
1041
+ const redeployRestoredApps = async () => {
1042
+ for (const app of appsAwayByRestore(project.root)) {
1043
+ if (stopping) return
1044
+ ctx.output.note(` App ${app.name}'s deployment ${app.deployment} went with the database restore: deploying it again (\`frontera app deploy --build\` in ${app.dir})`)
1045
+ const result = await runCli(project, ['app', 'deploy', '--build', '--dir', join(project.root, app.dir), '--profile', profiles.workspace], options.auth)
1046
+ ctx.output.note(result.ok ? ` ✓ App ${app.name} deployed again` : ` ✕ App ${app.name} wasn't deployed again: ${oneLine(applyErrorText(result.output))} — \`frontera app deploy\` in ${app.dir}`)
1047
+ }
1048
+ }
1049
+
1050
+ /** Secrets an apply in this round already said need a value: not said twice. */
1051
+ const secretsSaid = new Set<string>()
1052
+
1053
+ /**
1054
+ * Each secret `secrets.json` declares or a workflow or Function names that
1055
+ * the runtime has no value for (names and whether a value is set only; values
1056
+ * are never read or written): said on every start and after a database
1057
+ * restore, for unchanged items too, since a restore can leave a secret
1058
+ * listed without its value.
1059
+ */
1060
+ const sayProjectSecrets = async () => {
1061
+ const presence = await localSecretPresence(serviceUrl, creds)
1062
+ for (const line of projectSecretLines(project.root, presence)) {
1063
+ const secret = /Needs a value for ([A-Z][A-Z0-9_]*)/.exec(line)?.[1]
1064
+ if (secret && secretsSaid.has(secret)) continue
1065
+ ctx.output.note(` ${line.trim()}`)
1066
+ }
1067
+ secretsSaid.clear()
1068
+ }
1069
+
1070
+ /** Files already said not to be applied, so a save does not say it again. */
1071
+ const unappliedSaid = new Set<string>()
1072
+
514
1073
  /** What each failure last said in this drain, so a retry failing the same way is said once. */
515
1074
  const said = new Map<string, string>()
516
1075
 
@@ -518,18 +1077,48 @@ async function dev(ctx: CommandContext) {
518
1077
  const drain = async () => {
519
1078
  if (busy) return
520
1079
  busy = true
1080
+ // Why this round runs; "Applying" is said only once an item actually applies (`markApplying`).
1081
+ round = { since: null, trigger: nextTrigger ?? filesChangedTrigger(changedFiles), applied: 0, failed: 0 }
1082
+ nextTrigger = null
1083
+ changedFiles.clear()
521
1084
  said.clear()
1085
+ drains += 1
1086
+ runtimeDown = false
1087
+ let resumed = 0
522
1088
  try {
523
1089
  for (;;) {
524
- while (pending.size && !stopping) {
1090
+ while (pending.size && !stopping && !runtimeDown) {
525
1091
  const next = [...pending.values()].sort(byApplyOrder)[0]!
526
1092
  pending.delete(next.key)
527
1093
  try {
528
1094
  await runOne(next)
529
1095
  } catch (err) {
530
- ctx.output.note(` ✕ ${describeTarget(next)}: ${(err as Error).message}`)
1096
+ // A call before the apply (a lookup of the runtime's ids) that couldn't reach the runtime.
1097
+ if (!stopping && !(await runtimeAnswering(project))) {
1098
+ runtimeDown = true
1099
+ waiting.set(next.key, next)
1100
+ failing.set(next.key, { item: describeTarget(next), error: RUNTIME_DOWN_ERROR })
1101
+ ctx.output.note(` ✕ ${describeTarget(next)}: ${RUNTIME_STOPPED_LINE}`)
1102
+ } else ctx.output.note(` ✕ ${describeTarget(next)}: ${(err as Error).message}`)
1103
+ }
1104
+ }
1105
+ if (runtimeDown) {
1106
+ // The rest waits for the runtime too, rather than each failing the same way.
1107
+ for (const [key, rest] of pending) {
1108
+ waiting.set(key, rest)
1109
+ failing.set(key, { item: describeTarget(rest), error: RUNTIME_DOWN_ERROR })
531
1110
  }
1111
+ pending.clear()
532
1112
  }
1113
+ // What failed while the runtime was down is applied again once it answers (at most twice a drain;
1114
+ // after that, a watching `local dev` asks again every few seconds).
1115
+ if (!stopping && waiting.size && resumed < 2 && (await runtimeAnswering(project))) {
1116
+ resumed += 1
1117
+ runtimeDown = false
1118
+ resumeWaiting()
1119
+ continue
1120
+ }
1121
+ if (runtimeDown) break
533
1122
  // What failed is tried again once the queue is empty, and only while a round applied
534
1123
  // something it may have been waiting for; otherwise it waits for its own file to change.
535
1124
  if (stopping || !progressed || !failed.size) break
@@ -537,8 +1126,29 @@ async function dev(ctx: CommandContext) {
537
1126
  for (const [key, retry] of failed) pending.set(key, retry)
538
1127
  failed.clear()
539
1128
  }
1129
+ // Once the Actions are on the runtime: a local persona per approval step, and a submitter.
1130
+ if (personasDue && !stopping && !runtimeDown && !failing.has('blueprint')) {
1131
+ personasDue = false
1132
+ for (const line of await seedPersonas(project.root, serviceUrl, creds)) ctx.output.note(line)
1133
+ }
1134
+ // After a restore's applies: the secrets that lost their value with it, unchanged items' included.
1135
+ if (secretsDue && !stopping && !runtimeDown) {
1136
+ secretsDue = false
1137
+ await sayProjectSecrets()
1138
+ }
1139
+ // And the Custom Apps it took away, once what they read is applied again.
1140
+ if (appsDue && !stopping && !runtimeDown) {
1141
+ appsDue = false
1142
+ await redeployRestoredApps()
1143
+ }
540
1144
  } finally {
541
1145
  busy = false
1146
+ if (round.since) {
1147
+ recordApply(project, { since: round.since, finished: new Date().toISOString(), trigger: round.trigger, applied: round.applied, failed: round.failed })
1148
+ recordApplying(project, null)
1149
+ }
1150
+ // A file saved after the queue emptied, before this round ended: its own round.
1151
+ if (pending.size && !stopping && !runtimeDown) schedule()
542
1152
  }
543
1153
  }
544
1154
 
@@ -547,16 +1157,88 @@ async function dev(ctx: CommandContext) {
547
1157
  timer = setTimeout(() => void drain(), DEBOUNCE_MS)
548
1158
  }
549
1159
 
550
- /** What a changed path means: one file, every Function (a helper or the SDK changed), or a moved folder. */
551
- const queuePath = (rel: string) => {
552
- // Forge's state and the runtime's resolved copies are written under `.frontera/`: never a reason to apply.
553
- if (rel.split(sep).some((p) => p === '.frontera' || p === 'node_modules' || p === '.git')) return
1160
+ /** What waited for the runtime, queued again: said once. */
1161
+ const resumeWaiting = () => {
1162
+ if (!waiting.size) return
1163
+ ctx.output.note(` the local runtime is answering again: applying the ${waiting.size === 1 ? 'item' : `${waiting.size} items`} it missed`)
1164
+ nextTrigger ??= 'the runtime is answering again'
1165
+ for (const [key, target] of waiting) {
1166
+ pending.set(key, target)
1167
+ failing.delete(key)
1168
+ }
1169
+ waiting.clear()
1170
+ }
1171
+
1172
+ /** A target queued by a file event (`rel`, the changed path): whatever it re-applied before starts over (`ApplyWatchdog`). */
1173
+ const queueFromFile = (target: SyncTarget, rel: string) => {
1174
+ pending.set(target.key, target)
1175
+ changedFiles.add(rel)
1176
+ watchdog.fileEvent(target.key)
1177
+ }
1178
+
1179
+ /**
1180
+ * The database was restored to its baseline (`frontera local snapshot restore`, `local check`):
1181
+ * the record of what is applied was put back with it, so this watcher takes it too, and applies
1182
+ * again what it had applied since.
1183
+ */
1184
+ let restoreSeen = readBaseline(project, organizationId)?.restoredAt
1185
+ const afterRestore = () => {
1186
+ const record = readBaseline(project, organizationId)
1187
+ if (!record?.restoredAt || record.restoredAt === restoreSeen) return
1188
+ restoreSeen = record.restoredAt
1189
+ const now = loadHashes(project, organizationId)
1190
+ const changed = new Set([...applied.keys(), ...now.keys()].filter((k) => applied.get(k) !== now.get(k)))
1191
+ applied.clear()
1192
+ for (const [k, v] of now) applied.set(k, v)
1193
+ // A Custom App deployed after the baseline went with it: deployed again after this round.
1194
+ appsDue = true
1195
+ // A restore can leave a secret listed without its value, whether or not anything applies again.
1196
+ if (!changed.size) {
1197
+ if (appsAwayByRestore(project.root).length) {
1198
+ secretsDue = true
1199
+ nextTrigger = 'the database was restored to its baseline'
1200
+ if (!busy) schedule()
1201
+ return
1202
+ }
1203
+ if (!busy) void sayProjectSecrets()
1204
+ else secretsDue = true
1205
+ return
1206
+ }
1207
+ secretsDue = true
1208
+ nextTrigger = 'the database was restored to its baseline'
1209
+ for (const t of allTargets(project.root)) if (changed.has(t.key)) pending.set(t.key, t)
1210
+ ctx.output.note(` the database was restored to its baseline: applying ${changed.size === 1 ? 'one item' : `${changed.size} items`} applied since then again`)
1211
+ if (!busy) schedule()
1212
+ }
1213
+
1214
+ /**
1215
+ * What a changed path means: one file, every Function (a helper or the SDK changed), or a moved folder.
1216
+ * Whether it queued anything: a path that queues nothing never starts a round.
1217
+ */
1218
+ const queuePath = (rel: string): boolean => {
1219
+ // Forge's state, the coding agents' settings, screenshots and notes: never a reason to apply (`canChangeRuntime`).
1220
+ if (!canChangeRuntime(rel)) return false
554
1221
  const absolute = join(project.root, rel)
555
1222
  const direct = targetFor(project.root, absolute)
556
1223
  if (direct) {
557
- pending.set(direct.key, direct)
558
- return
1224
+ queueFromFile(direct, rel)
1225
+ if (direct.kind !== 'blueprint') return true
1226
+ }
1227
+ // A saved file that matches no kind is said once, with where it belongs.
1228
+ if (!unappliedSaid.has(rel) && existsSync(absolute)) {
1229
+ const why = (() => {
1230
+ try {
1231
+ return lstatSync(absolute).isFile() ? unappliedReason(project.root, rel) : null
1232
+ } catch {
1233
+ return null
1234
+ }
1235
+ })()
1236
+ if (why) {
1237
+ unappliedSaid.add(rel)
1238
+ ctx.output.note(` warning: ${rel} isn't applied: ${why}`)
1239
+ }
559
1240
  }
1241
+ if (direct) return true
560
1242
  const top = rel.split(sep)[0]
561
1243
  const isDir = (() => {
562
1244
  try {
@@ -567,13 +1249,53 @@ async function dev(ctx: CommandContext) {
567
1249
  })()
568
1250
  // A helper in functions/, the SDK, or a shared file a Function imports from outside functions/.
569
1251
  const helper = ((top === 'functions' || top === 'vendor') && /\.ts$/.test(rel) && !rel.endsWith('.test.ts')) || isSharedFunctionFile(rel.split(sep).join('/'))
570
- if (!isDir && !helper) return
1252
+ if (!isDir && !helper) return false
1253
+ let queued = false
571
1254
  for (const target of allTargets(project.root)) {
572
1255
  const path = target.kind === 'blueprint' ? 'blueprint' : target.file
573
- if (helper ? target.kind === 'function' : path === rel || path.startsWith(rel + sep)) pending.set(target.key, target)
1256
+ if (helper ? target.kind === 'function' : path === rel || path.startsWith(rel + sep)) {
1257
+ queueFromFile(target, rel)
1258
+ queued = true
1259
+ }
574
1260
  }
1261
+ return queued
575
1262
  }
576
1263
 
1264
+ /**
1265
+ * What the runtime holds that the files no longer do (`local-orphans.ts`): an Agent, workflow or
1266
+ * Function the project applied whose file is gone is taken out of service, reversibly, and said.
1267
+ * The runtime is asked only when an applied file is missing, so a save never costs a listing.
1268
+ */
1269
+ let sweeping: Promise<void> | null = null
1270
+ const sweepOrphans = (): Promise<void> =>
1271
+ (sweeping ??= (async () => {
1272
+ try {
1273
+ const known = loadHashes(project, organizationId)
1274
+ const present = new Set(allTargets(project.root).map((t) => t.key))
1275
+ if (![...known.keys()].some((k) => /^(agent|workflow|function):/.test(k) && !present.has(k))) return
1276
+ const call = orphanCalls(serviceUrl, creds)
1277
+ if (!call) return
1278
+ const orphans = findOrphans(project.root, await runtimeItems(call, creds.workspaceId), new Set(known.keys())).filter((o) => o.deleted)
1279
+ if (!orphans.length) return
1280
+ const { lines, forget } = await retireDeleted(project, organizationId, call, orphans)
1281
+ for (const line of lines) ctx.output.note(line)
1282
+ if (forget.length) {
1283
+ // Forgotten as applied, so the file, if it comes back unchanged, is applied again.
1284
+ for (const key of forget) {
1285
+ applied.delete(key)
1286
+ known.delete(key)
1287
+ }
1288
+ saveHashes(project, organizationId, known)
1289
+ // The runtime changed: Studio's lists and Forge's home read it again.
1290
+ recordApplied(project, { at: new Date().toISOString(), what: orphans.map((o) => `${o.kind} ${o.name}`).join(', '), ok: true })
1291
+ }
1292
+ } catch (err) {
1293
+ ctx.output.note(` warning: what the runtime holds without a file couldn’t be checked: ${(err as Error).message}`)
1294
+ } finally {
1295
+ sweeping = null
1296
+ }
1297
+ })())
1298
+
577
1299
  // Watch from the start, so a save during the first sync is not missed.
578
1300
  const once = flagBool(ctx, 'once')
579
1301
  // One watcher per project (Forge keeps one running; a second would apply every change twice). `--once` is always allowed.
@@ -588,37 +1310,100 @@ async function dev(ctx: CommandContext) {
588
1310
  }
589
1311
  writeFileSync(pidFile, String(process.pid))
590
1312
  }
1313
+ // Before the watcher and the first apply: the datasets it rewrites apply once, as made now.
1314
+ const sampleLines = await remakeStaleSampleData(project.root, flagBool(ctx, 'keep-sample-data'), flagBool(ctx, 'remake-sample-data'))
1315
+ for (const line of sampleLines) ctx.output.note(line)
591
1316
  const watcher = once
592
1317
  ? null
593
1318
  : watch(project.root, { recursive: true }, (_event, filename) => {
594
1319
  if (!filename || stopping) return
595
1320
  if (filename.toString() === join('.frontera', 'sync', 'remote.json')) void mirrorSettings()
596
- queuePath(filename.toString())
597
- if (!busy) schedule()
1321
+ if (filename.toString() === PROJECT_SECRETS_FILE) void sayProjectSecrets()
1322
+ if (filename.toString() === BASELINE_RECORD) afterRestore()
1323
+ // An Agent's folder, a workflow's or a Function's file deleted: what the runtime holds follows.
1324
+ if (/^(agents|workflows|functions)([\\/]|$)/.test(filename.toString()) && !existsSync(join(project.root, filename.toString()))) void sweepOrphans()
1325
+ // Only a change that queued an item starts a round: the watcher's own writes under `.frontera/`,
1326
+ // a screenshot, a coding agent's settings or a note in docs/ never do.
1327
+ if (queuePath(filename.toString()) && !busy) schedule()
598
1328
  })
599
1329
 
600
1330
  const targets = allTargets(project.root)
601
1331
  // Outcomes of files that are gone are forgotten, so the home lists what the project holds.
602
1332
  pruneBuild(project, organizationId, new Set(targets.map((t) => t.key)))
1333
+ // An Agent, workflow or Function whose file was deleted is taken out of service here, as the files now say.
1334
+ await sweepOrphans()
603
1335
  ctx.output.note(targets.length ? `Applying ${targets.length} item${targets.length === 1 ? '' : 's'} to the local runtime` : 'Nothing to apply yet')
1336
+ // Files in the applied folders that match no kind: said, not skipped in silence.
1337
+ for (const line of unappliedFiles(project.root)) {
1338
+ unappliedSaid.add(line.split(': ')[0]!)
1339
+ ctx.output.note(` warning: ${line.replace(/^([^:]+): /, "$1 isn't applied: ")}`)
1340
+ }
1341
+ // The vendored SDK's files the CLI wrote and nobody edited follow this CLI's copy; the rest are said below.
1342
+ const sdk = vendoredSdkState(project.root)
1343
+ // Never back to an older copy: one a newer CLI wrote is kept, and said below with the advice to upgrade.
1344
+ if (!sdk.unknown && !sdk.newer && (sdk.outdated.length || sdk.missing.length)) {
1345
+ const done = refreshVendoredSdk(project.root)
1346
+ ctx.output.note(` vendor/frontera/: ${done.replaced.length + done.added.length} file${done.replaced.length + done.added.length === 1 ? '' : 's'} of the Functions SDK brought to this CLI's copy (unedited since the CLI wrote them)`)
1347
+ }
1348
+ // An older project's root `bun test` sweeps the Apps' tests too: a bunfig.toml keeps it to functions/, written only when there is none.
1349
+ if (ensureProjectBunfig(project.root)) ctx.output.note(" bunfig.toml: written, so `bun test` here runs only the Functions' unit tests (functions/); an App's tests run in its folder")
1350
+ // What the project's tests and the runtime can't show: the pull's notes, sample data an older generator made, an older vendored SDK.
1351
+ // The sample data line above already said what there is to say of it.
1352
+ for (const line of noticeLines({ ...projectNotices(project.root), ...(sampleLines.length ? { sampleData: null } : {}) })) ctx.output.note(` warning: ${line}`)
604
1353
  for (const target of targets) pending.set(target.key, target)
1354
+ // The first round: the runtime just started (Forge starts this watcher with it), or `local dev` was run by hand.
1355
+ const startedAt = Date.parse(state.startedAt ?? '')
1356
+ nextTrigger = once ? 'frontera local dev --once' : Number.isFinite(startedAt) && Date.now() - startedAt < 5 * 60_000 ? 'runtime started' : 'frontera local dev started'
605
1357
  await drain()
606
1358
  if (unchanged) ctx.output.note(` ${unchanged} unchanged since the last apply (--reapply applies them again)`)
1359
+ await sayProjectSecrets()
607
1360
 
608
1361
  if (once) {
609
- if (failures) throw new CliError(`${failures} of ${targets.length} failed to apply`, { code: 'LOCAL_APPLY_FAILED', hint: 'fix the files above and run again' })
1362
+ // What still fails once every retry ran: a target that failed and then applied is not a failure.
1363
+ // And what the runtime refused inside a Blueprint that applied (a governed Action, a metric): failures
1364
+ // here as in `local status` and `local check`, not only warnings, so all three say one number.
1365
+ const blueprintEntry = loadBuild(project, organizationId).blueprint
1366
+ const current = blueprintEntry && blueprintEntry.hash === contentHash(project, { kind: 'blueprint', key: 'blueprint' }) ? blueprintEntry : undefined
1367
+ const byKey = new Map(targets.map((t) => [t.key, t]))
1368
+ const failures = applyFailures(failing, targets.length, current, (key) => {
1369
+ const target = byKey.get(key)
1370
+ return target ? { ref: refOfTarget(target), ...('file' in target ? { file: target.file } : {}) } : undefined
1371
+ })
1372
+ // Split as `local check` splits it: a failure only in items still as the pull wrote them is the workspace's, not this change's.
1373
+ const { made, inherited } = failures ? splitApplyFailures(failures.failed, unchangedSincePull(project.root), { strict: flagBool(ctx, 'strict') }) : { made: [], inherited: [] }
1374
+ const inheritedField = inherited.length ? { inheritedFailures: inherited } : {}
1375
+ if (failures && made.length) {
1376
+ const message = inherited.length ? `${failures.message}: ${made.length} changed or added here, ${inherited.length} inherited from the pull` : failures.message
1377
+ throw new CliError(message, { code: 'LOCAL_APPLY_FAILED', hint: 'fix the files above and run again; `frontera local status` lists each item that didn’t apply', details: { failed: made, ...inheritedField } })
1378
+ }
610
1379
  const done = targets.length - unchanged
611
- return { data: { applied: done, unchanged }, text: `Applied ${done}${unchanged ? `, ${unchanged} unchanged` : ''}` }
1380
+ if (inherited.length) {
1381
+ const summary = inheritedApplyLine(inherited.length)
1382
+ ctx.output.note(` ${summary}:`)
1383
+ for (const f of inherited) ctx.output.note(` ${f.ref ?? f.item}: ${oneLine(f.error)}`)
1384
+ return { data: { applied: done, unchanged, failed: 0, ...inheritedField, summary, ...(warned.size ? { warnings: [...warned.values()] } : {}) }, text: `Applied ${done}${unchanged ? `, ${unchanged} unchanged` : ''}; ${summary}` }
1385
+ }
1386
+ return { data: { applied: done, unchanged, ...(warned.size ? { warnings: [...warned.values()] } : {}) }, text: `Applied ${done}${unchanged ? `, ${unchanged} unchanged` : ''}` }
612
1387
  }
613
1388
 
614
1389
  ctx.output.note('Watching datasets/, blueprint/, knowledge/, skills/, mcp/, themes/, functions/, workflows/, agents/, chat-apps/, apps/, brand/ and library.json (Ctrl+C to stop)')
615
1390
  if (pending.size) schedule()
1391
+ // What failed while the runtime was down is applied once it answers again, not when its file next changes.
1392
+ const poller = setInterval(() => {
1393
+ if (!waiting.size || busy || stopping) return
1394
+ void runtimeAnswering(project).then((up) => {
1395
+ if (!up || busy || stopping || !waiting.size) return
1396
+ resumeWaiting()
1397
+ schedule()
1398
+ })
1399
+ }, 5_000)
616
1400
 
617
1401
  await new Promise<void>((resolve) => {
618
1402
  const stop = () => {
619
1403
  stopping = true
620
1404
  rmSync(pidFile, { force: true })
621
1405
  if (timer) clearTimeout(timer)
1406
+ clearInterval(poller)
622
1407
  watcher?.close()
623
1408
  stopChildren()
624
1409
  resolve()
@@ -634,11 +1419,13 @@ export const localDev: Command = {
634
1419
  noun: 'local',
635
1420
  verb: 'dev',
636
1421
  args: [],
637
- flags: { once: 'boolean', 'no-publish': 'boolean', reapply: 'boolean' },
1422
+ flags: { once: 'boolean', 'no-publish': 'boolean', reapply: 'boolean', 'keep-sample-data': 'boolean', 'remake-sample-data': 'boolean', strict: 'boolean' },
638
1423
  summary: 'Apply the project to the local runtime, then apply each saved file',
639
- examples: ['frontera local dev', 'frontera local dev --once', 'frontera local dev --no-publish', 'frontera local dev --once --reapply'],
1424
+ details: [
1425
+ 'With --once it exits 0 when everything applied, and also when every item that failed is still as the last pull wrote it: those failures are inherited from the workspace, listed apart (`inheritedFailures` with --json), as `frontera local check` lists them. Anything changed or added here that failed exits 1. --strict counts the inherited failures too.',
1426
+ ],
1427
+ examples: ['frontera local dev', 'frontera local dev --once', 'frontera local dev --no-publish', 'frontera local dev --once --reapply', 'frontera local dev --once --strict'],
640
1428
  offline: true,
641
- internal: true,
642
1429
  },
643
1430
  run: dev,
644
1431
  }