@frontera-sdk/cli 1.52.39 → 1.52.41

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (235) hide show
  1. package/README.md +9 -0
  2. package/package.json +6 -4
  3. package/src/adopt.ts +6 -2
  4. package/src/api/apps-api.ts +86 -4
  5. package/src/api/blueprint-authoring-api.ts +35 -11
  6. package/src/api/dataset-api.ts +29 -0
  7. package/src/api/governed-action-api.ts +90 -2
  8. package/src/api/platform-api.ts +87 -2
  9. package/src/api/workflow-api.ts +33 -2
  10. package/src/app-contract.ts +156 -0
  11. package/src/app-lock.ts +125 -0
  12. package/src/args.ts +14 -3
  13. package/src/auth-verify.ts +10 -0
  14. package/src/automation-template.ts +12 -1
  15. package/src/blueprint/action-rules.ts +516 -0
  16. package/src/blueprint/action-semantic-fit.ts +205 -0
  17. package/src/blueprint/compile.ts +51 -2
  18. package/src/blueprint/diff.ts +34 -2
  19. package/src/blueprint/metric-query.ts +215 -0
  20. package/src/blueprint/metric-rules.ts +122 -0
  21. package/src/blueprint/object-sets.ts +116 -0
  22. package/src/blueprint/ontology-edit-plan.ts +49 -5
  23. package/src/blueprint/projection.ts +24 -1
  24. package/src/blueprint/scaffold.ts +84 -42
  25. package/src/commands/action/available.ts +1 -1
  26. package/src/commands/action/criteria-refusal.ts +84 -0
  27. package/src/commands/action/decide.ts +5 -2
  28. package/src/commands/action/deploy.ts +13 -4
  29. package/src/commands/action/list.ts +134 -19
  30. package/src/commands/action/requests.ts +34 -6
  31. package/src/commands/action/submit.ts +28 -13
  32. package/src/commands/agent/draft-changes.ts +1 -1
  33. package/src/commands/agent/index-commands.ts +69 -7
  34. package/src/commands/agent/resolve.ts +2 -0
  35. package/src/commands/app/checks.ts +44 -0
  36. package/src/commands/app/deploy.ts +243 -60
  37. package/src/commands/app/deployments.ts +97 -0
  38. package/src/commands/app/dev.ts +103 -11
  39. package/src/commands/app/init.ts +36 -3
  40. package/src/commands/app/promote.ts +45 -19
  41. package/src/commands/app/pull.ts +3 -1
  42. package/src/commands/app/rollback.ts +46 -0
  43. package/src/commands/app/sdk.ts +54 -8
  44. package/src/commands/app/unpin.ts +27 -0
  45. package/src/commands/app/versions.ts +1 -1
  46. package/src/commands/automation/bindings.ts +4 -2
  47. package/src/commands/automation/build-entry.ts +4 -1
  48. package/src/commands/automation/index-commands.ts +3 -1
  49. package/src/commands/automation/pull.ts +6 -1
  50. package/src/commands/automation/run.ts +34 -9
  51. package/src/commands/blueprint/bind.ts +2 -1
  52. package/src/commands/blueprint/declarative.ts +332 -32
  53. package/src/commands/blueprint/editable.ts +3 -3
  54. package/src/commands/blueprint/get.ts +6 -2
  55. package/src/commands/blueprint/grants.ts +64 -10
  56. package/src/commands/blueprint/metric.ts +108 -0
  57. package/src/commands/blueprint/object-sets.ts +71 -0
  58. package/src/commands/blueprint/query.ts +155 -31
  59. package/src/commands/dataset/index-commands.ts +156 -7
  60. package/src/commands/knowledge/index-commands.ts +122 -20
  61. package/src/commands/local/check.ts +657 -44
  62. package/src/commands/local/data.ts +33 -12
  63. package/src/commands/local/dev.ts +876 -89
  64. package/src/commands/local/docs-mcp.ts +10 -18
  65. package/src/commands/local/index-commands.ts +300 -63
  66. package/src/commands/local/init.ts +18 -5
  67. package/src/commands/local/key.ts +0 -3
  68. package/src/commands/local/providers.ts +0 -7
  69. package/src/commands/local/remove.ts +116 -0
  70. package/src/commands/local/schema.ts +3 -4
  71. package/src/commands/local/sdk-refresh.ts +59 -0
  72. package/src/commands/local/snapshot.ts +100 -0
  73. package/src/commands/local/sync-request.ts +321 -14
  74. package/src/commands/local/sync.ts +103 -6
  75. package/src/commands/mcp/serve.ts +34 -0
  76. package/src/commands/registry.ts +54 -10
  77. package/src/commands/types.ts +29 -0
  78. package/src/commands/workflow/archive.ts +46 -0
  79. package/src/commands/workflow/get.ts +2 -1
  80. package/src/commands/workflow/index-commands.ts +5 -0
  81. package/src/commands/workflow/push.ts +49 -2
  82. package/src/commands/workflow/run.ts +65 -7
  83. package/src/commands/workflow/runs.ts +9 -6
  84. package/src/commands/workflow/webhook-test.ts +166 -0
  85. package/src/config.ts +43 -6
  86. package/src/dev-broker.ts +52 -0
  87. package/src/exit.ts +103 -4
  88. package/src/flag-help.ts +77 -17
  89. package/src/forge/claude-commands.ts +11 -6
  90. package/src/forge/claude-skills.ts +4 -4
  91. package/src/forge/touches.ts +15 -12
  92. package/src/function-outside-imports.ts +89 -0
  93. package/src/harness.ts +42 -24
  94. package/src/heal.ts +113 -2
  95. package/src/kit.ts +1 -1
  96. package/src/local/fix-words.ts +12 -0
  97. package/src/local/function-bundle-recovery.ts +775 -0
  98. package/src/local/local-actions.ts +103 -10
  99. package/src/local/local-agent-action-grants.ts +210 -0
  100. package/src/local/local-agent-checks.ts +232 -0
  101. package/src/local/local-agent-grants.ts +29 -7
  102. package/src/local/local-agent-lean-plugins.ts +68 -0
  103. package/src/local/local-agent-missing-tools.ts +68 -0
  104. package/src/local/local-agent-name.ts +27 -0
  105. package/src/local/local-app-code.ts +191 -0
  106. package/src/local/local-app-dev-files.ts +186 -0
  107. package/src/local/local-app-dev-session.ts +88 -0
  108. package/src/local/local-app-lock-install.ts +114 -0
  109. package/src/local/local-app-pulled.ts +167 -0
  110. package/src/local/local-app-restore.ts +111 -0
  111. package/src/local/local-app-runtime.ts +68 -0
  112. package/src/local/local-app-sdk-paths.ts +149 -0
  113. package/src/local/local-app-sdk.ts +509 -0
  114. package/src/local/local-applied.ts +212 -22
  115. package/src/local/local-apply-history.ts +84 -0
  116. package/src/local/local-apply-summary.ts +86 -0
  117. package/src/local/local-apply-watchdog.ts +95 -0
  118. package/src/local/local-apps.ts +181 -8
  119. package/src/local/local-blueprint-errors.ts +198 -0
  120. package/src/local/local-blueprint-outline.ts +31 -12
  121. package/src/local/local-blueprint-schemas.ts +582 -0
  122. package/src/local/local-build-inputs.ts +48 -0
  123. package/src/local/local-build-summary.ts +495 -28
  124. package/src/local/local-case-evidence.ts +120 -0
  125. package/src/local/local-case-not-run.ts +153 -0
  126. package/src/local/local-chat-apps.ts +93 -16
  127. package/src/local/local-check-inherited.ts +83 -0
  128. package/src/local/local-cities.ts +89 -0
  129. package/src/local/local-component-health.ts +119 -0
  130. package/src/local/local-control.ts +34 -7
  131. package/src/local/local-db-snapshot.ts +348 -0
  132. package/src/local/local-dev-references.ts +347 -36
  133. package/src/local/local-dev-sync.ts +141 -12
  134. package/src/local/local-docker.ts +35 -1
  135. package/src/local/local-docs-search.ts +1 -1
  136. package/src/local/local-docs.ts +281 -2
  137. package/src/local/local-empty-workflows.ts +92 -0
  138. package/src/local/local-env.ts +16 -1
  139. package/src/local/local-file-checks.ts +104 -0
  140. package/src/local/local-function-ids.ts +141 -0
  141. package/src/local/local-function-recovery.ts +141 -0
  142. package/src/local/local-function-state.ts +87 -0
  143. package/src/local/local-keys.ts +7 -5
  144. package/src/local/local-knowledge-documents.ts +1 -1
  145. package/src/local/local-knowledge-ingestion.ts +225 -0
  146. package/src/local/local-knowledge-uploads.ts +217 -0
  147. package/src/local/local-lessons.ts +1 -1
  148. package/src/local/local-library.ts +1 -1
  149. package/src/local/local-media-placeholders.ts +182 -0
  150. package/src/local/local-media-sets.ts +90 -10
  151. package/src/local/local-model-parameters.ts +89 -0
  152. package/src/local/local-object-sets.ts +18 -6
  153. package/src/local/local-operating.ts +35 -1
  154. package/src/local/local-org-settings.ts +206 -8
  155. package/src/local/local-orphans.ts +321 -0
  156. package/src/local/local-personas.ts +296 -0
  157. package/src/local/local-portability.ts +143 -18
  158. package/src/local/local-ports.ts +99 -7
  159. package/src/local/local-project-notices.ts +234 -0
  160. package/src/local/local-project-secrets.ts +133 -0
  161. package/src/local/local-project.ts +12 -0
  162. package/src/local/local-provider-refusal.ts +66 -0
  163. package/src/local/local-request-evidence.ts +112 -0
  164. package/src/local/local-runtime-health.ts +123 -0
  165. package/src/local/local-runtime-history.ts +50 -0
  166. package/src/local/local-runtime-target.ts +42 -0
  167. package/src/local/local-sample-data.ts +141 -0
  168. package/src/local/local-sample-remake.ts +138 -0
  169. package/src/local/local-scaffold.ts +336 -53
  170. package/src/local/local-schema-check.ts +148 -0
  171. package/src/local/local-schemas.ts +742 -25
  172. package/src/local/local-secret-carry.ts +107 -0
  173. package/src/local/local-secrets.ts +41 -11
  174. package/src/local/local-seed.ts +102 -26
  175. package/src/local/local-stand-ins.ts +3 -3
  176. package/src/local/local-state.ts +43 -2
  177. package/src/local/local-studio-server.ts +170 -7
  178. package/src/local/local-supervisor.ts +44 -6
  179. package/src/local/local-sync-merge.ts +1 -1
  180. package/src/local/local-sync-names.ts +2 -0
  181. package/src/local/local-sync-plan.ts +19 -8
  182. package/src/local/local-sync-push.ts +15 -12
  183. package/src/local/local-sync-refs.ts +49 -3
  184. package/src/local/local-sync-remote.ts +349 -38
  185. package/src/local/local-sync-snapshot.ts +18 -13
  186. package/src/local/local-synthetic-data.ts +2116 -99
  187. package/src/local/local-test-baseline.ts +113 -0
  188. package/src/local/local-test-cases.ts +76 -1
  189. package/src/local/local-test-runner.ts +582 -91
  190. package/src/local/local-test-runs.ts +13 -5
  191. package/src/local/local-vendored-sdk.ts +183 -0
  192. package/src/local/local-workflow-state.ts +16 -3
  193. package/src/local/local-workflow-tests.ts +482 -0
  194. package/src/local/local-workspace-grants.ts +81 -0
  195. package/src/main.ts +50 -9
  196. package/src/mcp/a11y-scan.ts +55 -0
  197. package/src/mcp/browser/ax-snapshot.ts +181 -0
  198. package/src/mcp/browser/credentials.ts +78 -0
  199. package/src/mcp/browser/frames.ts +336 -0
  200. package/src/mcp/browser/headless-session.ts +413 -0
  201. package/src/mcp/browser/headless-tab.ts +575 -0
  202. package/src/mcp/browser/keys.ts +70 -0
  203. package/src/mcp/browser/page-scripts.ts +129 -0
  204. package/src/mcp/browser/wait.ts +73 -0
  205. package/src/mcp/forge-link.ts +126 -0
  206. package/src/mcp/headless.ts +199 -0
  207. package/src/mcp/mcp-apps.ts +435 -0
  208. package/src/mcp/mcp-chat.ts +226 -0
  209. package/src/mcp/mcp-cli.ts +53 -0
  210. package/src/mcp/mcp-protocol.ts +122 -0
  211. package/src/mcp/mcp-runtime.ts +113 -0
  212. package/src/mcp/mcp-server.ts +108 -0
  213. package/src/mcp/mcp-traces.ts +409 -0
  214. package/src/mcp/page-observation.ts +193 -0
  215. package/src/mcp/tools/agent-tools.ts +700 -0
  216. package/src/mcp/tools/app-tools.ts +241 -0
  217. package/src/mcp/tools/browser-tools.ts +310 -0
  218. package/src/mcp/tools/docs-tools.ts +32 -0
  219. package/src/mcp/tools/project-tools.ts +205 -0
  220. package/src/mcp/tools/test-tools.ts +339 -0
  221. package/src/mcp/tools/workflow-tools.ts +611 -0
  222. package/src/output.ts +1 -1
  223. package/src/pack.ts +8 -3
  224. package/src/packaging.ts +1 -1
  225. package/src/project-bootstrap.ts +107 -4
  226. package/src/project-context.ts +18 -1
  227. package/src/project.ts +27 -2
  228. package/src/provenance.ts +25 -0
  229. package/src/render-evidence.ts +65 -7
  230. package/src/scopes.ts +1 -1
  231. package/src/template.ts +14 -8
  232. package/src/templates/next-app-files.ts +35 -6
  233. package/src/templates/next-skills.ts +61 -27
  234. package/src/vendor/kit-assets.json +6 -6
  235. package/src/vendor/sdk-sources.json +17 -14
@@ -5,11 +5,15 @@ import { extname, join, normalize } from 'node:path'
5
5
  import type { LocalProject } from './local-project'
6
6
  import { AgentFileError, saveAgentEdit, type AgentFileEdit } from './local-agent-files'
7
7
  import { solutionStatus } from './local-applied'
8
+ import { appCodeStates } from './local-apps'
8
9
  import { projectBlueprintOutline } from './local-blueprint-outline'
9
- import { itemBuild } from './local-build-summary'
10
- import { logPath, readApplied, readState, type ComponentName } from './local-state'
11
- import { appendTestCase, renderTestCase } from './local-test-cases'
10
+ import { currentBuildRows, currentBuildSummary } from './local-build-inputs'
11
+ import { appStatusRow, itemBuild } from './local-build-summary'
12
+ import { KnowledgeDocumentError, KNOWLEDGE_UPLOAD_EXTENSIONS, MAX_KNOWLEDGE_UPLOAD_BYTES, checkUpload, documentInFolder, knowledgeFolderOf, projectDocuments, removeKnowledgeDocument, removeRuntimeCopies, runtimeDocuments, saveKnowledgeDocument, type ServiceCall } from './local-knowledge-uploads'
13
+ import { isAlive, logPath, readApplied, readState, type ComponentName } from './local-state'
14
+ import { appendTestCase, decisionOfApprovals, MIXED_DECISIONS_LINE, noteRedactedVariables, redactedVariablesLine, redactedVariablesOfTimeline, renderTestCase, variablesOfTimeline } from './local-test-cases'
12
15
  import { findCaseFiles } from './local-test-runner'
16
+ import { workflowTestListing } from './local-workflow-tests'
13
17
  import { createTestRuns } from './local-test-runs'
14
18
  import { docsCopyPath } from './local-docs'
15
19
  import { standInMcp } from './local-stand-ins'
@@ -62,6 +66,16 @@ export function newSessionToken(): string {
62
66
  return randomBytes(24).toString('base64url')
63
67
  }
64
68
 
69
+ /** An Agent's display name from `agents/<slug>/agent.json` (`config.name`), or null when it gives none. */
70
+ export function agentFileName(root: string, slug: string): string | null {
71
+ try {
72
+ const name = (JSON.parse(readFileSync(join(root, 'agents', slug, 'agent.json'), 'utf8')) as { config?: { name?: unknown } }).config?.name
73
+ return typeof name === 'string' && name.trim() ? name.trim() : null
74
+ } catch {
75
+ return null
76
+ }
77
+ }
78
+
65
79
  function sameToken(a: string, b: string): boolean {
66
80
  const ab = Buffer.from(a)
67
81
  const bb = Buffer.from(b)
@@ -95,7 +109,7 @@ export function startStudioServer(opts: StudioServerOptions) {
95
109
  const json = (data: unknown, status = 200) =>
96
110
  new Response(JSON.stringify(data), { status, headers: { 'content-type': 'application/json; charset=utf-8' } })
97
111
 
98
- const localRoute = (url: URL): Response => {
112
+ const localRoute = (url: URL): Response | Promise<Response> => {
99
113
  if (url.pathname === '/__local/state') {
100
114
  const state = readState(project)
101
115
  if (!state) return json({ error: 'no local runtime state' }, 404)
@@ -125,6 +139,36 @@ export function startStudioServer(opts: StudioServerOptions) {
125
139
  if (!/^[a-z-]+\/[A-Za-z0-9][\w./-]{0,200}$/.test(ref)) return json({ error: 'name an item, as agent/<slug>' }, 400)
126
140
  return json({ build: itemBuild(project.root, ref, project.id) })
127
141
  }
142
+ // The build in a few numbers, counted as `local status` counts it (the runtime's Action deployments and
143
+ // secrets read the same way): Studio's footer says its failures, the number `local status` prints.
144
+ if (url.pathname === '/__local/build-summary') {
145
+ return currentBuildSummary(project.root, project.id, readState(project)).then((summary) =>
146
+ json({ summary: summary ? { problems: summary.problems, counts: summary.counts, headline: summary.headline } : null }),
147
+ )
148
+ }
149
+ // The build's rows of one kind (`?kind=function`), each with its state, reason and fix, as `local status` lists
150
+ // them: Studio's lists mark the ones that didn't apply or need setup, with the same words.
151
+ if (url.pathname === '/__local/builds') {
152
+ const kind = url.searchParams.get('kind') ?? ''
153
+ if (!/^[a-zA-Z-]{1,40}$/.test(kind)) return json({ error: 'name a kind, as function' }, 400)
154
+ return currentBuildRows(project.root, project.id, readState(project)).then((rows) =>
155
+ json({
156
+ rows: (rows ?? [])
157
+ .filter((r) => r.ref.startsWith(`${kind}/`))
158
+ .map((r) => ({ ref: r.ref, state: r.state, label: r.label, reason: r.reason, fix: r.fix ?? null, ...(r.warnings?.length ? { warnings: r.warnings } : {}) })),
159
+ }),
160
+ )
161
+ }
162
+ // Where each Custom App's code is (its folder, not pulled, or none yet), with its one status as `local status` says
163
+ // it (deployed to this runtime, built, not built, no code): Studio's Apps page says it, and how to work on it.
164
+ if (url.pathname === '/__local/apps') {
165
+ return json({
166
+ apps: appCodeStates(project.root).map((code) => {
167
+ const row = appStatusRow(project.root, code)
168
+ return { ...code, status: row.appStatus, label: row.label, fix: row.fix ?? null }
169
+ }),
170
+ })
171
+ }
128
172
  // The Blueprint's definitions as the files state them, so they show before anything is applied.
129
173
  if (url.pathname === '/__local/blueprint') {
130
174
  return json({ objectTypes: projectBlueprintOutline(project.root) })
@@ -132,11 +176,17 @@ export function startStudioServer(opts: StudioServerOptions) {
132
176
  // The solution's saved cases (`agents/<slug>/evals/*.yaml`), read fresh each time.
133
177
  if (url.pathname === '/__local/test-cases') {
134
178
  try {
135
- return json({ files: findCaseFiles(project.root) })
179
+ // Each Agent by the name its agent.json gives it, as the home and Agents say it, not its folder.
180
+ return json({ files: findCaseFiles(project.root).map((f) => ({ ...f, agentName: agentFileName(project.root, f.agent) })) })
136
181
  } catch (err) {
137
182
  return json({ error: `a case file could not be read: ${(err as Error).message}` }, 422)
138
183
  }
139
184
  }
185
+ // The workflows' test files (`workflows/tests/*.yaml`): `frontera local test` runs them; Evaluations lists them.
186
+ if (url.pathname === '/__local/workflow-tests') {
187
+ // Each case by name, with its last run (`frontera local test` keeps it), so Evaluations lists them.
188
+ return json({ files: workflowTestListing(project.root, project.stateDir) })
189
+ }
140
190
  if (url.pathname === '/__local/test-runs') return json({ runs: testRuns.list() })
141
191
  const runMatch = /^\/__local\/test-runs\/([0-9a-f-]+)$/.exec(url.pathname)
142
192
  if (runMatch) {
@@ -171,10 +221,122 @@ export function startStudioServer(opts: StudioServerOptions) {
171
221
  const agent = (await get(`/v1/config/agents/${encodeURIComponent(body.agentId)}`)) as { agentId?: string }
172
222
  const messages = (await get(`/v1/sessions/${encodeURIComponent(body.conversationId)}/messages`)) as unknown
173
223
  const list = Array.isArray(messages) ? messages : ((messages as { messages?: unknown[] })?.messages ?? [])
174
- const yaml = renderTestCase({ conversationId: body.conversationId, messages: list as never[], savedAt: new Date().toISOString() })
224
+ // The variables the conversation started with, when its Test setup recorded them: the case replays as the same customer.
225
+ const session = (await get(`/v1/config/agents/${encodeURIComponent(body.agentId)}/try/conversations/${encodeURIComponent(body.conversationId)}/session`).catch(() => null)) as { timeline?: Array<{ payload?: Record<string, unknown> | null }> } | null
226
+ const variables = variablesOfTimeline(session?.timeline ?? [])
227
+ // A sensitive variable is named in the setup, never its value: the case says to fill it in by hand.
228
+ const redacted = redactedVariablesOfTimeline(session?.timeline ?? [])
229
+ // The tool approvals it stopped on, as the person decided them: the case answers them the same way.
230
+ const cards = (await get(`/v1/chat/approvals?conversationId=${encodeURIComponent(body.conversationId)}`).catch(() => [])) as unknown
231
+ const { decision, mixed } = decisionOfApprovals(Array.isArray(cards) ? cards : [])
232
+ const yaml = noteRedactedVariables(renderTestCase({ conversationId: body.conversationId, messages: list as never[], savedAt: new Date().toISOString(), ...(variables ? { variables } : {}), ...(decision ? { decision } : {}) }), redacted)
175
233
  const path = appendTestCase(project.root, agent.agentId ?? body.agentId, yaml)
176
- return json({ path })
234
+ const notes = [...(redacted.length ? [redactedVariablesLine(redacted, path)] : []), ...(mixed ? [MIXED_DECISIONS_LINE] : [])]
235
+ return json({ path, ...(decision ? { decision } : {}), ...(redacted.length ? { redactedVariables: redacted } : {}), ...(notes.length ? { note: notes.join(' ') } : {}) })
236
+ } catch (err) {
237
+ return json({ error: (err as Error).message }, 502)
238
+ }
239
+ }
240
+
241
+ /** The local service as the seeded owner, for the routes that act on the runtime themselves; null before it is seeded. */
242
+ const serviceCall = (): ServiceCall | null => {
243
+ const creds = readState(project)?.credentials
244
+ const bearer = creds?.session ?? creds?.workspaceKey
245
+ if (!creds || !bearer) return null
246
+ return async (method, path) => {
247
+ const res = await fetch(new URL(path, opts.serviceUrl), { method, headers: { authorization: `Bearer ${bearer}`, 'x-org-id': creds.organizationId, 'x-workspace-id': creds.workspaceId } })
248
+ const text = await res.text()
249
+ if (!res.ok) throw new Error(`the runtime answered ${res.status}${text ? `: ${text.slice(0, 200)}` : ''}`)
250
+ return text ? (JSON.parse(text) as unknown) : null
251
+ }
252
+ }
253
+
254
+ /** Whether a `local dev` watcher is applying this project's saved files (Forge keeps one running). */
255
+ const watching = (): boolean => {
256
+ try {
257
+ return isAlive(Number(readFileSync(join(project.stateDir, 'dev.pid'), 'utf8').trim()))
258
+ } catch {
259
+ return false
260
+ }
261
+ }
262
+
263
+ /**
264
+ * A knowledge base's documents as the project's files (`local-knowledge-uploads.ts`):
265
+ * GET /__local/knowledge?base=<name>&id=<knowledge id> its folder, its documents and whether the runtime has each, its apply state
266
+ * POST /__local/knowledge/documents?base=&id=[&replace=1] save one document (multipart `file`) into its folder
267
+ * DELETE /__local/knowledge/documents?base=&id=&sourceId= take a document off the runtime and out of the folder
268
+ */
269
+ const knowledgeRoute = async (req: Request, url: URL): Promise<Response> => {
270
+ const base = (url.searchParams.get('base') ?? '').trim()
271
+ const id = url.searchParams.get('id') ?? ''
272
+ if (!base || base.length > 200) return json({ error: 'name the knowledge base, as base=<name>' }, 400)
273
+ if (id && !/^[\w-]{1,80}$/.test(id)) return json({ error: 'id is not a knowledge base id' }, 400)
274
+ const folder = knowledgeFolderOf(project.root, base)
275
+ try {
276
+ if (url.pathname === '/__local/knowledge' && req.method === 'GET') {
277
+ const accepted = { extensions: KNOWLEDGE_UPLOAD_EXTENSIONS, maxBytes: MAX_KNOWLEDGE_UPLOAD_BYTES }
278
+ if (!folder) return json({ folder: null, documents: [], apply: null, watching: watching(), accepted })
279
+ const call = id ? serviceCall() : null
280
+ const onRuntime = call ? await runtimeDocuments(call, id).then((docs) => new Set(docs.map((d) => d.path)), () => null) : null
281
+ const status = solutionStatus(project, readState(project)?.credentials?.organizationId ?? null, (t) => t.kind === 'knowledge' && t.name === folder)[0]
282
+ return json({
283
+ folder: `knowledge/${folder}/`,
284
+ documents: projectDocuments(project.root, folder, onRuntime),
285
+ apply: status ? { state: status.state, ...(status.detail ? { detail: status.detail } : {}), ...(status.fix ? { fix: status.fix } : {}), ...(status.waiting ? { waiting: true } : {}) } : null,
286
+ watching: watching(),
287
+ accepted,
288
+ })
289
+ }
290
+ if (url.pathname !== '/__local/knowledge/documents') return json({ error: 'not found' }, 404)
291
+ if (req.method === 'POST') {
292
+ if (!folder) return json({ error: `${base} has no folder in the project (knowledge/<name>/knowledge.json), so a document can't be saved to it here` }, 404)
293
+ // Refused before the body is read: the form's boundary and fields add little beyond the file.
294
+ if (Number(req.headers.get('content-length') ?? 0) > MAX_KNOWLEDGE_UPLOAD_BYTES + 64 * 1024) {
295
+ return json({ error: `the document is larger than ${MAX_KNOWLEDGE_UPLOAD_BYTES / 1024 / 1024} MB, the most a knowledge base takes` }, 413)
296
+ }
297
+ const form = await req.formData().catch(() => null)
298
+ const file = form?.get('file')
299
+ if (!(file instanceof Blob)) return json({ error: 'send the document as the multipart field "file"' }, 400)
300
+ const name = checkUpload((file as File).name ?? '', file.size)
301
+ const replace = url.searchParams.get('replace') === '1'
302
+ // Replacing: the runtime's copy goes first, so the apply uploads the new content (it only adds what the runtime lacks).
303
+ if (replace && id && documentInFolder(project.root, folder, name)) {
304
+ const call = serviceCall()
305
+ if (call) {
306
+ try {
307
+ await removeRuntimeCopies(call, id, name)
308
+ } catch (err) {
309
+ return json({ error: `the runtime's copy of ${name} couldn't be taken off (${(err as Error).message}), so knowledge/${folder}/${name} is left as it was` }, 502)
310
+ }
311
+ }
312
+ }
313
+ const saved = saveKnowledgeDocument(project.root, folder, name, new Uint8Array(await file.arrayBuffer()), { replace })
314
+ return json({ ...saved, watching: watching() })
315
+ }
316
+ if (req.method === 'DELETE') {
317
+ const sourceId = url.searchParams.get('sourceId') ?? ''
318
+ if (!id || !/^[\w-]{1,80}$/.test(sourceId)) return json({ error: 'name the document, as id=<knowledge id>&sourceId=<document id>' }, 400)
319
+ const call = serviceCall()
320
+ if (!call) return json({ error: 'the runtime is not seeded yet' }, 409)
321
+ const source = (await runtimeDocuments(call, id)).find((d) => d.id === sourceId)
322
+ if (!source) return json({ error: 'the runtime has no such document in this knowledge base' }, 404)
323
+ await call('DELETE', `/v1/workspace-knowledge/${encodeURIComponent(id)}/sources/${encodeURIComponent(sourceId)}`)
324
+ // Its file too, so the next apply does not upload it again; a base with no folder had only the runtime's copy.
325
+ // A runtime document of a kind or name the folder can't hold (a spreadsheet uploaded on the runtime) has no file to remove.
326
+ const removed = (() => {
327
+ if (!folder) return { path: null, removed: false }
328
+ try {
329
+ return removeKnowledgeDocument(project.root, folder, source.path)
330
+ } catch (err) {
331
+ if (err instanceof KnowledgeDocumentError && err.status !== 409) return { path: null, removed: false }
332
+ throw err
333
+ }
334
+ })()
335
+ return json({ path: removed.path, removedFile: removed.removed })
336
+ }
337
+ return json({ error: 'not found' }, 404)
177
338
  } catch (err) {
339
+ if (err instanceof KnowledgeDocumentError) return json({ error: err.message, ...err.extra }, err.status)
178
340
  return json({ error: (err as Error).message }, 502)
179
341
  }
180
342
  }
@@ -316,6 +478,7 @@ export function startStudioServer(opts: StudioServerOptions) {
316
478
  const started = testRuns.start({ agent: typeof body.agent === 'string' ? body.agent : undefined, live: body.live === true })
317
479
  return 'run' in started ? json(started.run, 202) : json({ error: started.error }, started.status)
318
480
  }
481
+ if (url.pathname === '/__local/knowledge' || url.pathname === '/__local/knowledge/documents') return knowledgeRoute(req, url)
319
482
  if (url.pathname.startsWith('/__local/')) return localRoute(url)
320
483
  // `/api/…` is the service; `/api` alone is Studio's API page.
321
484
  if (url.pathname.startsWith('/api/')) return proxy(req, url)
@@ -5,6 +5,7 @@ import { join } from 'node:path'
5
5
  import {
6
6
  containerState,
7
7
  copyFromContainer,
8
+ dbVolumeName,
8
9
  execInContainer,
9
10
  followLogs,
10
11
  imagePresent,
@@ -13,6 +14,7 @@ import {
13
14
  runContainer,
14
15
  startContainer,
15
16
  stopContainer,
17
+ storageVolumeName,
16
18
  } from './local-docker'
17
19
  import {
18
20
  CATALOG_DB,
@@ -30,14 +32,15 @@ import {
30
32
  type LocalConfig,
31
33
  type LocalSecrets,
32
34
  } from './local-env'
33
- import { keylessEmbeddings, providerEnv } from './local-keys'
35
+ import { localEmbeddingsReachable, providerEnv } from './local-keys'
34
36
  import { applyProviderSetup, readProviderSetup, rememberSlugs, writeProviderSetup } from './local-providers'
35
37
  import type { PortBlock } from './local-ports'
36
38
  import { startProcess, stopProcess, waitForHttp } from './local-process'
37
39
  import type { LocalProject } from './local-project'
38
40
  import { startLocalEmbeddings } from './local-embeddings'
39
41
  import { LOCAL_TRACE_KEYS, startLocalTraces } from './local-traces'
40
- import { copyDocs, customerDocsBundle, IMAGE_DOCS_PATH } from './local-docs'
42
+ import { recordRuntime } from './local-runtime-history'
43
+ import { buildDocs, checkoutDocsStale, copyDocs, customerDocsBundle, IMAGE_DOCS_PATH, writeDocsBuildMarker } from './local-docs'
41
44
  import { keepSchemas, type SchemaSource } from './local-schemas'
42
45
  import { applyModelKeys, ensureBlueprint, ensureOrgKey, ensureProviderModels, retireLocalModel, seedLocalRuntime, signIn } from './local-seed'
43
46
  import {
@@ -80,7 +83,7 @@ export const IMAGES = {
80
83
  export const containerName = (project: LocalProject, component: ComponentName) =>
81
84
  `frontera-${project.id}-${component}`
82
85
 
83
- export const volumeName = (project: LocalProject) => `frontera-${project.id}-db`
86
+ export const volumeName = (project: LocalProject) => dbVolumeName(project.id)
84
87
 
85
88
  interface Running {
86
89
  children: Map<ComponentName, ChildProcess>
@@ -144,6 +147,8 @@ export async function runSupervisor(project: LocalProject, ports: PortBlock, ses
144
147
  }
145
148
  for (const name of [...running.containers].reverse()) stopContainer(name)
146
149
  for (const c of state.components) if (c.status === 'running' || c.status === 'starting') set(c.name, { status: 'stopped' })
150
+ // A failed start says so where it failed (`fail`); this is the stop of a runtime that was up.
151
+ if (code === 0) recordRuntime(project, { event: 'stopped', at: new Date().toISOString() })
147
152
  state = { ...state, phase: 'stopped', supervisorPid: undefined }
148
153
  save()
149
154
  process.exit(code)
@@ -155,6 +160,7 @@ export async function runSupervisor(project: LocalProject, ports: PortBlock, ses
155
160
  set(name, { status: 'failed', detail })
156
161
  state = { ...state, phase: 'failed', error: `${name}: ${detail}` }
157
162
  save()
163
+ recordRuntime(project, { event: 'failed', at: new Date().toISOString(), since: state.startedAt, detail: `${name}: ${detail}` })
158
164
  await shutdown(1)
159
165
  }
160
166
 
@@ -278,6 +284,7 @@ export async function runSupervisor(project: LocalProject, ports: PortBlock, ses
278
284
  set('studio', { status: 'running', startedAt: new Date().toISOString(), pid: process.pid })
279
285
  state = { ...state, phase: 'ready', error: undefined }
280
286
  save()
287
+ recordRuntime(project, { event: 'started', at: new Date().toISOString(), since: state.startedAt })
281
288
  } catch (err) {
282
289
  const failing = state.components.find((c) => c.status === 'starting')?.name ?? 'service'
283
290
  await fail(failing, (err as Error).message)
@@ -393,7 +400,7 @@ async function startStorage(project: LocalProject, ports: PortBlock, secrets: Lo
393
400
  projectId: project.id,
394
401
  ports: [{ host: ports.storage, container: 9000 }],
395
402
  env: { RUSTFS_ACCESS_KEY: secrets.storageAccessKey, RUSTFS_SECRET_KEY: secrets.storageSecretKey },
396
- volumes: [{ name: `frontera-${project.id}-storage`, path: '/data' }],
403
+ volumes: [{ name: storageVolumeName(project.id), path: '/data' }],
397
404
  memory: '256m',
398
405
  cpus: '0.5',
399
406
  args: ['server', '/data'],
@@ -462,7 +469,7 @@ async function startService(
462
469
  runnerEnabled,
463
470
  jobsEnabled,
464
471
  storageEnabled,
465
- keylessEmbeddings: keylessEmbeddings(),
472
+ keylessEmbeddings: localEmbeddingsReachable(),
466
473
  documentationBundle: config.mode === 'source' ? customerDocsBundle(config.platformSource) : null,
467
474
  })
468
475
  const log = logPath(project, 'service')
@@ -479,6 +486,7 @@ async function startService(
479
486
  if (!ok) throw new Error('the service did not become healthy; see `frontera local logs service`')
480
487
  set('service', { status: 'running', pid: child.pid, startedAt: new Date().toISOString() })
481
488
  keepDocs(project, { bundle: customerDocsBundle(config.platformSource) }, log)
489
+ refreshCheckoutDocs(project, config.platformSource, log)
482
490
  return
483
491
  }
484
492
  const name = containerName(project, 'service')
@@ -504,7 +512,7 @@ async function startService(
504
512
  keepDocs(project, { fromContainer: (dest) => copyFromContainer(name, IMAGE_DOCS_PATH, dest) }, log)
505
513
  }
506
514
 
507
- /** The release's documentation, for the solution's coding agents (`local docs-mcp`). */
515
+ /** The release's documentation, for the solution's coding agents (`frontera mcp`). */
508
516
  function keepDocs(project: LocalProject, from: { bundle?: string | null; fromContainer?: (dest: string) => boolean }, log: string): void {
509
517
  if (!from.bundle && !from.fromContainer) {
510
518
  appendFileSync(log, '[docs] no customer documentation in the platform checkout; run `bun run docs:build` there\n')
@@ -514,6 +522,36 @@ function keepDocs(project: LocalProject, from: { bundle?: string | null; fromCon
514
522
  if (problem) appendFileSync(log, `[docs] the documentation for coding agents was not copied: ${problem}\n`)
515
523
  }
516
524
 
525
+ /**
526
+ * From a checkout whose documentation build is behind it (no build, guides
527
+ * changed since, another version): build it in the background, at low
528
+ * priority, and refresh the copy when it is done. Start never waits for it:
529
+ * the copy from the previous build serves until then, and the service rereads
530
+ * the checkout's publication when it changes on disk.
531
+ */
532
+ function refreshCheckoutDocs(project: LocalProject, platformSource: string | undefined, log: string): void {
533
+ if (!platformSource) return
534
+ const why = checkoutDocsStale(platformSource)
535
+ if (!why) {
536
+ // Current: a failure an earlier start recorded no longer applies.
537
+ writeDocsBuildMarker(project, null)
538
+ return
539
+ }
540
+ appendFileSync(log, `[docs] ${why}; building it in the background (nice -n 10 bun run docs:build); the previous copy serves until it is done\n`)
541
+ // What `local status` and Forge's Docs say while it builds ("rebuilding"), and if it fails.
542
+ writeDocsBuildMarker(project, { state: 'building', at: new Date().toISOString(), pid: process.pid, why })
543
+ void buildDocs(platformSource, log).then((built) => {
544
+ if (!built) {
545
+ writeDocsBuildMarker(project, { state: 'failed', at: new Date().toISOString(), why })
546
+ appendFileSync(log, '[docs] the documentation build did not finish; coding agents keep the previous copy (`bun run docs:build` in the checkout shows why)\n')
547
+ return
548
+ }
549
+ keepDocs(project, { bundle: customerDocsBundle(platformSource) }, log)
550
+ writeDocsBuildMarker(project, null)
551
+ appendFileSync(log, '[docs] built the documentation and refreshed the copy for coding agents\n')
552
+ })
553
+ }
554
+
517
555
  /** The formats of the project's files, for its coding agents and editors (`local schema`). Logged, never a reason to fail start. */
518
556
  async function keepFileSchemas(project: LocalProject, source: SchemaSource, log: string): Promise<void> {
519
557
  for (const problem of await keepSchemas(project, source)) appendFileSync(log, `[schemas] not kept: ${problem}\n`)
@@ -372,7 +372,7 @@ export function mergeItemFiles(
372
372
  theirs: Record<string, string | null>,
373
373
  opts: Omit<MergeOptions, 'offset'> = {},
374
374
  ): { files: FileMerge[]; conflicts: number } {
375
- const paths = [...new Set([...Object.keys(base), ...Object.keys(ours), ...Object.keys(theirs)])].sort()
375
+ const paths = [...new Set([...Object.keys(base), ...Object.keys(ours), ...Object.keys(theirs)])].sort((a, b) => (String(a) < String(b) ? -1 : String(a) > String(b) ? 1 : 0))
376
376
  const files: FileMerge[] = []
377
377
  let offset = 0
378
378
  for (const path of paths) {
@@ -131,6 +131,8 @@ export function itemTitle(kind: string, name: string, files: Record<string, stri
131
131
  const own = declared(doc?.config, 'name') ?? declared(doc, 'name')
132
132
  if (own) return own
133
133
  }
134
+ // The workspace's own grants beyond read (`blueprint/workspace-grants.yaml`): one file, no display name.
135
+ if (kind === 'blueprint' && name === 'workspace-grants') return 'Workspace grants'
134
136
  if (kind === 'blueprint') {
135
137
  const [dir, apiName] = name.split('/')
136
138
  const file = Object.entries(files).find(([p]) => p.startsWith(`blueprint/${dir}/${apiName}.`))?.[1]
@@ -84,6 +84,13 @@ export interface RemoteItem {
84
84
  * is being read; `from`, the snapshot's copy, once fetched.
85
85
  */
86
86
  assets?: ItemAsset[]
87
+ /**
88
+ * Files the pull recovered from the item's deployed bundle because its
89
+ * source lacks them (a Function's imports from beside its folder), by
90
+ * project path. Pulled like any other file; listed so it is never mistaken
91
+ * for the original source.
92
+ */
93
+ recovered?: string[]
87
94
  }
88
95
 
89
96
  export interface ItemAsset {
@@ -193,6 +200,8 @@ export interface LockEntry {
193
200
  pulledAt: string
194
201
  /** Pulled with conflicts not yet resolved; the base above is still the last clean pull. */
195
202
  conflict?: LockConflict
203
+ /** Its files the pull recovered from the deployed bundle, not its source (`RemoteItem.recovered`). */
204
+ recovered?: string[]
196
205
  }
197
206
 
198
207
  /**
@@ -214,7 +223,7 @@ export function hash(text: string): string {
214
223
  }
215
224
 
216
225
  export function itemHash(files: Record<string, string>): string {
217
- return hash(Object.keys(files).sort().map((p) => `${p}\0${files[p]}`).join('\0\0'))
226
+ return hash(Object.keys(files).sort((a, b) => (String(a) < String(b) ? -1 : String(a) > String(b) ? 1 : 0)).map((p) => `${p}\0${files[p]}`).join('\0\0'))
218
227
  }
219
228
 
220
229
  export function readLock(root: string): SyncLock | null {
@@ -231,7 +240,7 @@ export function readLock(root: string): SyncLock | null {
231
240
 
232
241
  /** Sorted keys, two spaces, a final newline: a lock diffs cleanly in review. Always written as v2. */
233
242
  export function writeLock(root: string, lock: SyncLock): void {
234
- const items = Object.fromEntries(Object.keys(lock.items).sort().map((k) => [k, sortEntry(lock.items[k]!)]))
243
+ const items = Object.fromEntries(Object.keys(lock.items).sort((a, b) => (String(a) < String(b) ? -1 : String(a) > String(b) ? 1 : 0)).map((k) => [k, sortEntry(lock.items[k]!)]))
235
244
  writeFileSync(join(root, LOCK_FILE), `${JSON.stringify({ version: 2, workspace: lock.workspace, ...(lock.pulledAt ? { pulledAt: lock.pulledAt } : {}), items }, null, 2)}\n`)
236
245
  }
237
246
 
@@ -240,11 +249,12 @@ function sortEntry(entry: LockEntry): LockEntry {
240
249
  ...(entry.id ? { id: entry.id } : {}),
241
250
  ...(entry.revision ? { revision: entry.revision } : {}),
242
251
  remote: entry.remote,
243
- files: Object.fromEntries(Object.keys(entry.files).sort().map((k) => [k, entry.files[k]!])),
252
+ files: Object.fromEntries(Object.keys(entry.files).sort((a, b) => (String(a) < String(b) ? -1 : String(a) > String(b) ? 1 : 0)).map((k) => [k, entry.files[k]!])),
244
253
  ...(entry.version ? { version: entry.version } : {}),
245
254
  ...(entry.state ? { state: entry.state } : {}),
246
255
  pulledAt: entry.pulledAt,
247
256
  ...(entry.conflict ? { conflict: sortConflict(entry.conflict) } : {}),
257
+ ...(entry.recovered?.length ? { recovered: [...entry.recovered].sort() } : {}),
248
258
  }
249
259
  }
250
260
 
@@ -252,7 +262,7 @@ function sortConflict(c: LockConflict): LockConflict {
252
262
  return {
253
263
  remote: c.remote,
254
264
  ...(c.revision ? { revision: c.revision } : {}),
255
- files: [...c.files].sort(),
265
+ files: [...c.files].sort((a, b) => (String(a) < String(b) ? -1 : String(a) > String(b) ? 1 : 0)),
256
266
  at: c.at,
257
267
  ...(c.count !== undefined ? { count: c.count } : {}),
258
268
  ...(c.id ? { id: c.id } : {}),
@@ -390,7 +400,7 @@ export function computePlan(root: string, remote: RemoteItem[], lock: SyncLock |
390
400
  for (const item of remote) {
391
401
  seen.add(item.ref)
392
402
  const entry = lock?.items[item.ref]
393
- const paths = [...new Set([...Object.keys(item.files), ...Object.keys(entry?.files ?? {})])].sort()
403
+ const paths = [...new Set([...Object.keys(item.files), ...Object.keys(entry?.files ?? {})])].sort((a, b) => (String(a) < String(b) ? -1 : String(a) > String(b) ? 1 : 0))
394
404
  const files: FileChange[] = paths.map((path) => ({ path, local: readComparable(root, path, opts.base), remote: item.files[path] ?? null }))
395
405
  // An Agent's file that neither the base nor the workspace has but the folder does (its first tools, in a new
396
406
  // `bindings.json`) is a change here too, as push reads it: otherwise status says nothing while push proposes it.
@@ -475,6 +485,7 @@ function entryFor(item: RemoteItem, now: string): LockEntry {
475
485
  ...(item.version ? { version: item.version } : {}),
476
486
  ...(item.state ? { state: item.state } : {}),
477
487
  pulledAt: now,
488
+ ...(item.recovered?.length ? { recovered: [...item.recovered].sort() } : {}),
478
489
  }
479
490
  }
480
491
 
@@ -542,7 +553,7 @@ export function applyItems(root: string, workspace: string, items: RemoteItem[],
542
553
  for (const item of items) {
543
554
  const previous = next.items[item.ref]
544
555
  if (opts.merge?.has(item.ref)) {
545
- const paths = [...new Set([...Object.keys(item.files), ...Object.keys(previous?.files ?? {})])].sort()
556
+ const paths = [...new Set([...Object.keys(item.files), ...Object.keys(previous?.files ?? {})])].sort((a, b) => (String(a) < String(b) ? -1 : String(a) > String(b) ? 1 : 0))
546
557
  const result = mergeItemFiles(baseFiles(previous, opts.base), localFiles(root, paths), item.files)
547
558
  if (result.conflicts) {
548
559
  const files = result.files.filter((f) => f.conflicts.length).map((f) => f.path)
@@ -622,7 +633,7 @@ export function refreshConflict(root: string, workspace: string, item: RemoteIte
622
633
  const previous = next.items[item.ref]
623
634
  const remote = itemHash(item.files)
624
635
  if (!previous?.conflict || previous.conflict.remote === remote) return { lock: next, outcome: 'unchanged', conflicts: previous?.conflict?.count ?? 0, written: [], removed: [] }
625
- const paths = [...new Set([...Object.keys(item.files), ...Object.keys(previous.files), ...previous.conflict.files])].sort()
636
+ const paths = [...new Set([...Object.keys(item.files), ...Object.keys(previous.files), ...previous.conflict.files])].sort((a, b) => (String(a) < String(b) ? -1 : String(a) > String(b) ? 1 : 0))
626
637
  const result = mergeItemFiles(baseFiles(previous, base), localFiles(root, paths), item.files)
627
638
  const now = new Date().toISOString()
628
639
  const written: string[] = []
@@ -662,7 +673,7 @@ export type Resolution = { edited: true } | { keep?: Side; picks?: Map<number, C
662
673
  export function resolveItem(root: string, workspace: string, item: RemoteItem, lock: SyncLock | null, how: Resolution, base?: (path: string) => string | null): { written: string[]; removed: string[]; lock: SyncLock } {
663
674
  const next: SyncLock = { version: 2, workspace, items: { ...(lock?.workspace === workspace ? lock.items : {}) } }
664
675
  const previous = next.items[item.ref]
665
- const paths = [...new Set([...Object.keys(item.files), ...Object.keys(previous?.files ?? {}), ...(previous?.conflict?.files ?? [])])].sort()
676
+ const paths = [...new Set([...Object.keys(item.files), ...Object.keys(previous?.files ?? {}), ...(previous?.conflict?.files ?? [])])].sort((a, b) => (String(a) < String(b) ? -1 : String(a) > String(b) ? 1 : 0))
666
677
  const written: string[] = []
667
678
  const removed: string[] = []
668
679
  if (!('edited' in how)) {
@@ -1,13 +1,15 @@
1
1
  import { createHash } from 'node:crypto'
2
- import { appendFileSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
2
+ import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
3
3
  import { dirname, join, posix } from 'node:path'
4
4
 
5
+ import { ignoreInFronteraDir } from '../project-context'
5
6
  import matter from 'gray-matter'
6
7
  import { parse as parseYaml } from 'yaml'
7
8
 
8
9
  import { mimeFromPath } from '../commands/skill/bundle-commands'
9
10
  import { writeFileAtomic } from '../fs-atomic'
10
11
  import { CliError } from '../errors'
12
+ import { readWorkspaceGrants, WORKSPACE_GRANTS_FILE, WORKSPACE_GRANTS_NAME, WORKSPACE_GRANTS_REF } from './local-workspace-grants'
11
13
  import { documentChanges, localDocuments, splitDocumentPath, type PulledDocument } from './local-knowledge-documents'
12
14
  import { QUALITY_CHECKS_FILE, qualityChecksPath, readQualityChecks } from './local-quality-checks'
13
15
  import { lessonsPath, lessonsProposal } from './local-lessons'
@@ -226,7 +228,7 @@ export function itemFiles(root: string, kind: SyncKind, name: string, entry?: Lo
226
228
  // What the last pull wrote for it comes along when it is still here (an agent's files are fixed; the rest may not be).
227
229
  if (kind !== 'agent' && kind !== 'knowledge') for (const p of Object.keys(entry?.files ?? {})) if (!paths.includes(p)) paths.push(p)
228
230
  const files: Record<string, string> = {}
229
- for (const path of [...new Set(paths)].sort()) {
231
+ for (const path of [...new Set(paths)].sort((a, b) => (String(a) < String(b) ? -1 : String(a) > String(b) ? 1 : 0))) {
230
232
  if (path.endsWith('.workspace') || assets.includes(path)) continue
231
233
  const text = readLocal(root, path)
232
234
  if (text === null) continue
@@ -266,6 +268,8 @@ export function projectRefs(root: string): Map<string, { kind: SyncKind; name: s
266
268
  for (const n of dirs('skills')) if (existsSync(join(root, 'skills', n, 'SKILL.md'))) add('skill', n)
267
269
  for (const n of dirs('knowledge')) if (existsSync(join(root, 'knowledge', n, 'knowledge.json'))) add('knowledge', n)
268
270
  for (const dir of BLUEPRINT_DIRS) for (const f of dirs(`blueprint/${dir}`)) if (/\.(ya?ml|json)$/.test(f)) add('blueprint', `${dir}/${f.replace(/\.(ya?ml|json)$/, '')}`)
271
+ // The workspace's own grants beyond read: one Blueprint item, `blueprint/workspace-grants`.
272
+ if (existsSync(join(root, WORKSPACE_GRANTS_FILE))) add('blueprint', WORKSPACE_GRANTS_NAME)
269
273
  for (const f of dirs('mcp')) if (f.endsWith('.plugin.json') && f !== '.plugin.json') add('plugin', f.slice(0, -'.plugin.json'.length))
270
274
  for (const f of dirs('chat-apps')) if (f.endsWith('.json') && f !== '.json') add('chat-app', f.slice(0, -'.json'.length))
271
275
  for (const slug of dirs('agents')) if (existsSync(join(root, lessonsPath(slug)))) add('lessons', slug)
@@ -368,7 +372,7 @@ export function pushCandidates(input: CandidateInput): PushItem[] {
368
372
  const here = projectRefs(root)
369
373
  const refs = new Set<string>([...plan.map((p) => p.ref), ...here.keys()])
370
374
  const out: PushItem[] = []
371
- for (const ref of [...refs].sort()) {
375
+ for (const ref of [...refs].sort((a, b) => (String(a) < String(b) ? -1 : String(a) > String(b) ? 1 : 0))) {
372
376
  const slash = ref.indexOf('/')
373
377
  const kind = (byPlan.get(ref)?.kind ?? here.get(ref)?.kind ?? ref.slice(0, slash)) as SyncKind
374
378
  const name = byPlan.get(ref)?.name ?? here.get(ref)?.name ?? ref.slice(slash + 1)
@@ -400,7 +404,7 @@ export function pushCandidates(input: CandidateInput): PushItem[] {
400
404
  if (status === 'same' && Object.keys(files).length === 0) continue
401
405
  const isNew = !entry && !theirs
402
406
  const baseFiles: Record<string, string> = !isNew && theirs && entry && itemHash(theirs.files) === entry.remote ? theirs.files : {}
403
- const diffPaths = [...new Set([...Object.keys(baseFiles), ...Object.keys(files)])].sort()
407
+ const diffPaths = [...new Set([...Object.keys(baseFiles), ...Object.keys(files)])].sort((a, b) => (String(a) < String(b) ? -1 : String(a) > String(b) ? 1 : 0))
404
408
  const diff: PushFile[] = diffPaths.map((path) => ({ path, base: baseFiles[path] ?? null, local: files[path] ?? null })).filter((f) => f.base !== f.local)
405
409
  const item: PushItem = {
406
410
  ref,
@@ -441,12 +445,15 @@ export function pushCandidates(input: CandidateInput): PushItem[] {
441
445
  else if (status === 'conflict') block('moved', BLOCK_REASON.moved)
442
446
  else if (status === 'unresolved') block('conflicts', BLOCK_REASON.conflicts)
443
447
  else if (deleted && NOT_DELETED[kind]) block('shape', NOT_DELETED[kind]!)
448
+ // Never proposed as a deletion: the platform refuses one, since deleting the file changes no grant.
449
+ else if (deleted && ref === WORKSPACE_GRANTS_REF) block('shape', `Deleting ${WORKSPACE_GRANTS_FILE} changes no grant: to take read_sensitive away, put the file back and list the type with \`permissions: [read]\``)
444
450
  else if (status === 'deleted-remote') block('workspace-deleted', BLOCK_REASON['workspace-deleted'])
445
451
  else if (deleted) {
446
452
  // Proposable: Apply deletes it there, after everything that names it is updated.
447
453
  } else if (problem) block('shape', problem)
448
454
  else if (!Object.keys(files).length) block('shape', 'No files to propose')
449
455
  else if (kind === 'blueprint' && Object.keys(files).length > 1) block('shape', `${Object.keys(files).join(' and ')}: keep one file per Blueprint artifact`)
456
+ else if (ref === WORKSPACE_GRANTS_REF && readWorkspaceGrants(root)?.problems.length) block('shape', readWorkspaceGrants(root)!.problems.join('; '))
450
457
  else if (assetState?.changed && entry?.revision && theirs?.revision && entry.revision !== theirs.revision && textSame) {
451
458
  // A pull never writes over an asset the folder has, so one the workspace changed since reads as a change here.
452
459
  block('moved', 'Its assets changed in the workspace since your last pull: delete the copies here to bring the workspace’s, then propose')
@@ -577,12 +584,8 @@ const REQUESTS_DIR = join(SYNC_DIR, 'requests')
577
584
 
578
585
  /** `.frontera/sync/` stays out of git, the requests with it. */
579
586
  function ignoreSync(root: string): void {
580
- const ignore = join(root, '.frontera', '.gitignore')
581
- mkdirSync(dirname(ignore), { recursive: true })
582
- const text = existsSync(ignore) ? readFileSync(ignore, 'utf8') : ''
583
- if (text.split(/\r?\n/).some((l) => l.trim() === 'sync/' || l.trim() === 'sync')) return
584
- if (text) appendFileSync(ignore, `${text.endsWith('\n') ? '' : '\n'}sync/\n`)
585
- else writeFileSync(ignore, 'local/\nsync/\n')
587
+ // A pulled or linked project: its sync state, and this machine's profile binding, stay out of git.
588
+ ignoreInFronteraDir(root, ['local/', 'sync/'])
586
589
  }
587
590
 
588
591
  /** The requests file for this workspace; another workspace's (a relinked project) reads as none. */
@@ -626,7 +629,7 @@ export function recordRequest(root: string, workspace: string, view: RequestView
626
629
  const file = readRequests(root, workspace)
627
630
  const now = (opts.now ?? new Date()).toISOString()
628
631
  const before = file.requests[view.request.id]
629
- const refs = view.items.map((i) => i.ref).sort()
632
+ const refs = view.items.map((i) => i.ref).sort((a, b) => (String(a) < String(b) ? -1 : String(a) > String(b) ? 1 : 0))
630
633
  const titles = Object.fromEntries(refs.map((r) => [r, opts.titles?.[r] ?? before?.titles[r] ?? r]))
631
634
  file.requests[view.request.id] = {
632
635
  id: view.request.id,
@@ -713,7 +716,7 @@ export function sameDocument(path: string, a: string, b: string): boolean {
713
716
  /** Quality checks as a set: by question, each one's lists in order of their words. */
714
717
  function checkSet(text: string): unknown {
715
718
  return readQualityChecks(text)
716
- .map((c) => ({ ...c, documents: [...(c.documents ?? [])].sort(), keywords: [...(c.keywords ?? [])].sort() }))
719
+ .map((c) => ({ ...c, documents: [...(c.documents ?? [])].sort((a, b) => (String(a) < String(b) ? -1 : String(a) > String(b) ? 1 : 0)), keywords: [...(c.keywords ?? [])].sort((a, b) => (String(a) < String(b) ? -1 : String(a) > String(b) ? 1 : 0)) }))
717
720
  .sort((x, y) => x.question.localeCompare(y.question))
718
721
  }
719
722