@frontera-sdk/cli 1.52.28 → 1.52.30

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 (59) hide show
  1. package/package.json +5 -4
  2. package/src/commands/local/data.ts +60 -0
  3. package/src/commands/local/dev.ts +361 -18
  4. package/src/commands/local/index-commands.ts +11 -3
  5. package/src/commands/local/init.ts +88 -7
  6. package/src/commands/local/sync-request.ts +750 -0
  7. package/src/commands/local/sync.ts +941 -0
  8. package/src/commands/skill/bundle-commands.ts +9 -6
  9. package/src/errors.ts +4 -1
  10. package/src/exit.ts +3 -0
  11. package/src/flag-help.ts +48 -0
  12. package/src/local/local-actions.ts +306 -0
  13. package/src/local/local-adapt.ts +216 -0
  14. package/src/local/local-agent-files.ts +9 -2
  15. package/src/local/local-agent-grants.ts +125 -0
  16. package/src/local/local-agent-name.ts +71 -0
  17. package/src/local/local-applied.ts +334 -3
  18. package/src/local/local-apps.ts +156 -0
  19. package/src/local/local-blueprint-outline.ts +465 -0
  20. package/src/local/local-brand.ts +123 -0
  21. package/src/local/local-build-summary.ts +106 -0
  22. package/src/local/local-chat-apps.ts +170 -0
  23. package/src/local/local-dev-references.ts +1132 -0
  24. package/src/local/local-dev-sync.ts +171 -7
  25. package/src/local/local-docs.ts +53 -5
  26. package/src/local/local-env.ts +2 -0
  27. package/src/local/local-function-draft.ts +33 -0
  28. package/src/local/local-function-shared.ts +65 -0
  29. package/src/local/local-function-state.ts +34 -0
  30. package/src/local/local-knowledge-documents.ts +302 -0
  31. package/src/local/local-lessons.ts +278 -0
  32. package/src/local/local-library.ts +124 -0
  33. package/src/local/local-media-sets.ts +31 -0
  34. package/src/local/local-object-sets.ts +181 -0
  35. package/src/local/local-objects.ts +159 -0
  36. package/src/local/local-operating.ts +9 -5
  37. package/src/local/local-org-settings.ts +118 -0
  38. package/src/local/local-portability.ts +346 -0
  39. package/src/local/local-project.ts +6 -2
  40. package/src/local/local-quality-checks.ts +171 -0
  41. package/src/local/local-scaffold.ts +14 -11
  42. package/src/local/local-schemas.ts +33 -2
  43. package/src/local/local-secrets.ts +306 -0
  44. package/src/local/local-stand-ins.ts +442 -0
  45. package/src/local/local-studio-server.ts +23 -2
  46. package/src/local/local-sync-base.ts +237 -0
  47. package/src/local/local-sync-client.ts +93 -0
  48. package/src/local/local-sync-link.ts +122 -0
  49. package/src/local/local-sync-merge.ts +395 -0
  50. package/src/local/local-sync-names.ts +169 -0
  51. package/src/local/local-sync-plan.ts +717 -0
  52. package/src/local/local-sync-push.ts +986 -0
  53. package/src/local/local-sync-refs.ts +221 -0
  54. package/src/local/local-sync-remote.ts +1482 -0
  55. package/src/local/local-sync-snapshot.ts +526 -0
  56. package/src/local/local-synthetic-data.ts +374 -0
  57. package/src/local/local-test-runner.ts +172 -14
  58. package/src/local/local-themes.ts +201 -0
  59. package/src/local/local-workflow-state.ts +131 -0
@@ -0,0 +1,1132 @@
1
+ import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
2
+ import { dirname, join } from 'node:path'
3
+
4
+ import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'
5
+
6
+ import type { LocalAction } from './local-actions'
7
+ import { absentWhy, adaptOperating, plainLinks, projectProvides } from './local-adapt'
8
+ import { applyAgentGrants, type AgentGrant } from './local-agent-grants'
9
+ import { oneLine, refWords } from './local-applied'
10
+ import { readableName } from './local-sync-names'
11
+ import { ensureObjectDefinitions } from './local-objects'
12
+ import { applyQualityChecks, QUALITY_CHECKS_FILE, readQualityChecks, sourcePaths, sourceRows } from './local-quality-checks'
13
+ import { readLock } from './local-sync-plan'
14
+ import { applyErrorText } from './local-apply-error'
15
+ import { localAdaptation } from './local-blueprint-outline'
16
+ import { actionFit, classify, objectTypeFit, objectTypeNeeds } from './local-portability'
17
+ import type { SyncProfiles, SyncTarget } from './local-dev-sync'
18
+ import type { LocalProject } from './local-project'
19
+ import { syncClient } from './local-sync-client'
20
+ import { readProviderSetup } from './local-providers'
21
+ import { fillPluginSecrets, localSecretNames, needsValueLine, NON_SECRET_HEADERS, pluginSecretInputs, pluginSecretName } from './local-secrets'
22
+ import { describeUnresolved, governedActionIds, isUuid, kindsIn, parseRef, referencesIn, toIds, type Directory } from './local-sync-refs'
23
+ import { buildDirectory, rows } from './local-sync-remote'
24
+ import type { LocalCredentials } from './local-state'
25
+ import { addStandInServers, dropPlatformOnly, dropStandInTriggers, platformOnlyNames, standInNames } from './local-stand-ins'
26
+
27
+ /**
28
+ * Project files name other resources by `kind/name`; a runtime wants its own
29
+ * ids. Before `local dev` applies an Agent, a workflow or an Agent's behavior
30
+ * settings, the names are looked up on the local runtime and a resolved copy
31
+ * is applied (`.frontera/local/resolved/`), so the file in the project keeps
32
+ * the names and stays portable.
33
+ *
34
+ * A file that names something the project holds but the runtime does not
35
+ * have yet waits for it: the message names the field and what is missing,
36
+ * and `local dev` applies it again once that is there. What nothing in the
37
+ * project provides is applied around (`local-adapt.ts`): an Agent goes without
38
+ * the Knowledge, Skill or Plugin, a link in guidance stays as plain words, and
39
+ * behavior settings lose only the procedure step, action or scope entry that
40
+ * names it. Each says so, so one missing piece does not keep the Agent from
41
+ * running.
42
+ */
43
+
44
+ export interface RunResult {
45
+ ok: boolean
46
+ output: string
47
+ }
48
+
49
+ /** The local runtime's resources by name, read with its own keys. */
50
+ export async function localDirectory(serviceUrl: string, creds: LocalCredentials): Promise<Directory> {
51
+ if (!creds.workspaceKey) return {}
52
+ const client = syncClient(serviceUrl, creds.workspaceKey)
53
+ const { directory, lists } = await buildDirectory({
54
+ workspace: client,
55
+ organization: creds.orgKey ? syncClient(serviceUrl, creds.orgKey) : undefined,
56
+ workspaceId: creds.workspaceId,
57
+ })
58
+ // The Agent list leaves out Agents never published, and locally every Agent is a
59
+ // draft: one the list does not have is looked up by name when a file names it.
60
+ agentLookups.set(directory, client)
61
+ if (creds.session) ownerCalls.set(directory, ownerCall(serviceUrl, creds))
62
+ if (creds.orgKey) orgLookups.set(directory, syncClient(serviceUrl, creds.orgKey))
63
+ functionVersions.set(directory, new Map(lists.functions.flatMap((f) => (f.liveVersion ? [[f.slug, f.liveVersion] as const] : []))))
64
+ return directory
65
+ }
66
+
67
+ /** Calls as the runtime's owner, for routes a workspace key may not use (object definitions). */
68
+ const ownerCalls = new WeakMap<Directory, (method: string, path: string, body?: unknown) => Promise<{ status: number; body: unknown }>>()
69
+
70
+ function ownerCall(serviceUrl: string, creds: LocalCredentials) {
71
+ return async (method: string, path: string, body?: unknown) => {
72
+ const res = await fetch(new URL(path, serviceUrl), {
73
+ method,
74
+ headers: { authorization: `Bearer ${creds.session}`, 'x-org-id': creds.organizationId, 'x-workspace-id': creds.workspaceId, ...(body === undefined ? {} : { 'content-type': 'application/json' }) },
75
+ body: body === undefined ? undefined : JSON.stringify(body),
76
+ })
77
+ const text = await res.text()
78
+ let parsed: unknown = text
79
+ try {
80
+ parsed = JSON.parse(text)
81
+ } catch {
82
+ // Not JSON: kept as text for the message.
83
+ }
84
+ return { status: res.status, body: parsed }
85
+ }
86
+ }
87
+
88
+ /** The runtime's organization key client, for reads a workspace key may not make (the model catalog's kinds). */
89
+ const orgLookups = new WeakMap<Directory, ReturnType<typeof syncClient>>()
90
+
91
+ /** The runtime's voice models by slug; null when they could not be read. */
92
+ async function runtimeVoices(dir: Directory): Promise<string[] | null> {
93
+ const client = orgLookups.get(dir)
94
+ if (!client) return null
95
+ try {
96
+ return rows<{ slug?: string; kind?: string }>(await client.get('/v1/config/models/catalog')).flatMap((m) => (m.kind === 'voice' && m.slug ? [m.slug] : []))
97
+ } catch {
98
+ return null
99
+ }
100
+ }
101
+
102
+ /**
103
+ * An Agent's voice names a voice in the organization's catalog
104
+ * (`config.voice.voiceSlug`). The runtime resolves a slug it lacks to no
105
+ * voice at all, silently, so here it is swapped for the runtime's first
106
+ * voice, as a missing model is, or left out when the runtime has none; each
107
+ * says so, as an adaptation.
108
+ */
109
+ export function substituteVoice(doc: unknown, voices: string[] | null, warnings: string[], label: string): void {
110
+ const config = (doc as { config?: { voice?: Record<string, unknown> | null } } | null)?.config
111
+ const slug = typeof config?.voice?.voiceSlug === 'string' ? config.voice.voiceSlug.trim() : ''
112
+ if (!slug || !voices || voices.includes(slug)) return
113
+ const usable = voices[0]
114
+ if (usable) {
115
+ config!.voice = { ...config!.voice, voiceSlug: usable }
116
+ warnings.push(` warning: ${label} speaks with the voice ${slug}, which this runtime does not have; it uses ${usable} here`)
117
+ return
118
+ }
119
+ const { voiceSlug: _missing, ...rest } = config!.voice!
120
+ config!.voice = rest
121
+ warnings.push(` warning: ${label} speaks with the voice ${slug}, and this runtime has no voices: it has no voice here`)
122
+ }
123
+
124
+ const agentLookups = new WeakMap<Directory, ReturnType<typeof syncClient>>()
125
+ /** Each Function's live version on the runtime, by slug: a workflow step's version pin is rewritten to it. */
126
+ const functionVersions = new WeakMap<Directory, Map<string, number>>()
127
+
128
+ /** The person the runtime's workspace key acts as (its owner), for a Plugin trigger's account. */
129
+ async function localOwner(dir: Directory): Promise<string | null> {
130
+ const me = await agentLookups.get(dir)?.find<{ principal?: string | null }>('/v1/whoami').catch(() => null)
131
+ return me?.principal ?? null
132
+ }
133
+
134
+ /**
135
+ * What a workflow names that only means something on the workspace, made to
136
+ * mean the same here (design §8):
137
+ * - A Function step pinned to a version (`params.version`) runs that
138
+ * Function's version on this runtime, where numbering starts again: the one
139
+ * deployed from the project. A pin to a version older than the one pulled
140
+ * says so, as the project holds only the pulled code.
141
+ * - A Plugin trigger acting as a person of the workspace (`account.userId`)
142
+ * acts as this runtime's owner.
143
+ */
144
+ async function localizeWorkflow(doc: unknown, dir: Directory, root: string, warnings: string[]): Promise<void> {
145
+ if (!doc || typeof doc !== 'object') return
146
+ const versions = functionVersions.get(dir) ?? new Map<string, number>()
147
+ const lock = readLock(root)
148
+ const visit = (v: unknown) => {
149
+ if (Array.isArray(v)) return v.forEach(visit)
150
+ if (!v || typeof v !== 'object') return
151
+ const o = v as Record<string, unknown>
152
+ const params = o.params as Record<string, unknown> | undefined
153
+ const ref = typeof o.resourceId === 'string' ? parseRef(o.resourceId) : null
154
+ if (o.kind === 'function' && ref?.kind === 'function' && params && typeof params.version === 'number') {
155
+ const here = versions.get(ref.name)
156
+ const pulled = Number(lock?.items[`function/${ref.name}`]?.version?.replace(/^v/, ''))
157
+ if (here) {
158
+ if (Number.isFinite(pulled) && pulled !== params.version) warnings.push(` warning: Step ${String(o.id)} pins Function ${ref.name} v${params.version}; here it runs the project’s copy (v${pulled} in the workspace)`)
159
+ params.version = here
160
+ } else delete params.version
161
+ }
162
+ for (const x of Object.values(o)) visit(x)
163
+ }
164
+ visit((doc as Record<string, unknown>).nodes)
165
+ const triggers = Array.isArray((doc as Record<string, unknown>).trigger) ? ((doc as Record<string, unknown>).trigger as Array<Record<string, unknown>>) : []
166
+ const asPerson = triggers.filter((t) => t?.kind === 'plugin' && (t.account as { kind?: string } | undefined)?.kind === 'user')
167
+ if (asPerson.length) {
168
+ const owner = await localOwner(dir)
169
+ for (const t of asPerson) t.account = owner ? { kind: 'user', userId: owner } : { kind: 'workspace' }
170
+ }
171
+ }
172
+
173
+ /** Look up, by name, the Agents a document names that the directory has not seen. */
174
+ async function fillAgents(doc: unknown, dir: Directory): Promise<void> {
175
+ const client = agentLookups.get(dir)
176
+ if (!client) return
177
+ dir.agent ??= new Map()
178
+ for (const ref of referencesIn(doc)) {
179
+ if (ref.kind !== 'agent' || dir.agent.has(ref.name)) continue
180
+ const found = await client.find<{ id?: string }>(`/v1/config/agents/${encodeURIComponent(ref.name)}`).catch(() => null)
181
+ if (found?.id) dir.agent.set(ref.name, found.id)
182
+ }
183
+ }
184
+
185
+ /** Relation lists on an Agent whose rows can be left out when what they point at is missing. */
186
+ const OPTIONAL_RELATIONS: Array<(doc: Record<string, unknown>) => { rows: unknown[]; set: (rows: unknown[]) => void } | null> = [
187
+ (d) => {
188
+ const skills = d.skills as { workspaceBindings?: unknown[] } | undefined
189
+ return Array.isArray(skills?.workspaceBindings) ? { rows: skills!.workspaceBindings!, set: (rows) => (skills!.workspaceBindings = rows) } : null
190
+ },
191
+ ...['knowledgeAttachments', 'appInstalls', 'functions', 'objectBindings'].map((key) => (d: Record<string, unknown>) =>
192
+ Array.isArray(d[key]) ? { rows: d[key] as unknown[], set: (rows: unknown[]) => (d[key] = rows) } : null,
193
+ ),
194
+ ]
195
+
196
+ /** A Plugin's tools on this runtime, by name → capability id, read with the runtime's workspace key. */
197
+ async function localTools(dir: Directory, installId: string): Promise<Map<string, string>> {
198
+ const client = agentLookups.get(dir)
199
+ if (!client) return new Map()
200
+ const listed = await client.get(`/v1/apps/${encodeURIComponent(installId)}/capabilities`).catch(() => null)
201
+ return new Map(rows<{ id?: string; name?: string }>(listed).flatMap((r) => (r.id && r.name ? [[r.name, r.id] as const] : [])))
202
+ }
203
+
204
+ /**
205
+ * An Agent's own settings for Plugin tools (`pluginTools`: a sensitive tool
206
+ * granted to it, an approval it overrides), named by Plugin and tool, become
207
+ * the runtime's capability rows (`appCapabilities`). A Plugin the runtime does
208
+ * not have is already left off the Agent, with its warning; a tool the
209
+ * runtime's Plugin does not list is said once.
210
+ */
211
+ async function applyPluginTools(doc: Record<string, unknown>, dir: Directory, warnings: string[]): Promise<void> {
212
+ if (!Array.isArray(doc.pluginTools)) return
213
+ const out: Array<Record<string, unknown>> = []
214
+ const byInstall = new Map<string, Map<string, string>>()
215
+ for (const row of doc.pluginTools as Array<Record<string, unknown>>) {
216
+ const ref = typeof row.plugin === 'string' ? parseRef(row.plugin) : null
217
+ const installId = ref?.kind === 'plugin' ? dir.plugin?.get(ref.name) : undefined
218
+ if (!ref || !installId || typeof row.tool !== 'string') continue
219
+ if (!byInstall.has(installId)) byInstall.set(installId, await localTools(dir, installId))
220
+ const capabilityId = byInstall.get(installId)!.get(row.tool)
221
+ if (!capabilityId) {
222
+ warnings.push(` warning: Plugin ${ref.name} has no tool ${row.tool} here, so the Agent’s setting for it is not applied`)
223
+ continue
224
+ }
225
+ const { plugin: _plugin, tool: _tool, ...rest } = row
226
+ out.push({ capabilityId, ...rest })
227
+ }
228
+ delete doc.pluginTools
229
+ doc.appCapabilities = out
230
+ }
231
+
232
+ /** Whether a workflow has something `localizeWorkflow` changes: a Function version pin, a Plugin trigger acting as a person. */
233
+ function workflowNeedsLocalizing(doc: unknown): boolean {
234
+ const d = (doc ?? {}) as Record<string, unknown>
235
+ const triggers = Array.isArray(d.trigger) ? (d.trigger as Array<Record<string, unknown>>) : []
236
+ if (triggers.some((t) => t?.kind === 'plugin' && (t.account as { kind?: string } | undefined)?.kind === 'user')) return true
237
+ let pinned = false
238
+ const visit = (v: unknown) => {
239
+ if (pinned) return
240
+ if (Array.isArray(v)) return v.forEach(visit)
241
+ if (!v || typeof v !== 'object') return
242
+ const o = v as Record<string, unknown>
243
+ if (o.kind === 'function' && typeof (o.params as Record<string, unknown> | undefined)?.version === 'number') pinned = true
244
+ for (const x of Object.values(o)) visit(x)
245
+ }
246
+ visit(d.nodes)
247
+ return pinned
248
+ }
249
+
250
+ /**
251
+ * A workflow without the triggers that name what nothing in the project
252
+ * provides (a Plugin trigger on a built-in connection): the workflow still
253
+ * runs from its other triggers, by hand or from an Agent. Changes `doc`.
254
+ */
255
+ function dropTriggers(doc: Record<string, unknown>, items: Array<{ path: string; value: string }>): string[] {
256
+ const triggers = doc.trigger as Array<Record<string, unknown>> | undefined
257
+ if (!Array.isArray(triggers) || !items.length) return []
258
+ const gone = new Map<number, string>()
259
+ for (const u of items) gone.set(Number(u.path.split('.')[1]), u.value)
260
+ doc.trigger = triggers.filter((_, i) => !gone.has(i))
261
+ return [...gone].map(([i, ref]) => {
262
+ const t = triggers[i] ?? {}
263
+ const what = t.kind === 'plugin' && typeof t.event === 'string' ? `Plugin trigger ${t.event}` : `${String(t.kind ?? 'A')} trigger`
264
+ return ` warning: Its ${what} is left out here: it uses ${refWords(ref)}, which ${absentWhy(ref)}`
265
+ })
266
+ }
267
+
268
+ function readDoc(path: string): unknown {
269
+ const text = readFileSync(path, 'utf8')
270
+ return /\.json$/.test(path) ? JSON.parse(text) : parseYaml(text)
271
+ }
272
+
273
+ export interface Resolved {
274
+ ok: boolean
275
+ /** Project-relative path to apply instead of the target's own file; absent when the file names nothing. */
276
+ file?: string
277
+ /** The resolved document, for behavior settings sent over the API. */
278
+ doc?: unknown
279
+ /** Warnings and, when not ok, the reasons. */
280
+ output: string
281
+ /** Not ok because it names these (`workflow/telco-fault-triage`), which the runtime does not have yet. */
282
+ missing?: string[]
283
+ /** Not ok because it names this many governed Actions by the platform's id. */
284
+ unnamedActions?: number
285
+ }
286
+
287
+ /**
288
+ * Resolve a target's `kind/name` references against the runtime. Returns the
289
+ * file unchanged (no `file`) when it names nothing.
290
+ */
291
+ /**
292
+ * A model the runtime's catalog does not have (a pulled Agent on the platform's
293
+ * own model) runs on the model chosen for new Agents here, and says so:
294
+ * the Agent and its workflows work locally instead of failing on a name.
295
+ */
296
+ function substituteModels(doc: unknown, dir: Directory, warnings: string[], label: string): unknown {
297
+ const models = dir.model
298
+ if (!models?.size) return doc
299
+ // The model chosen for new Agents, or else the first the runtime offers.
300
+ const fallback = readProviderSetup().defaults.agentModel
301
+ const usable = fallback && models.has(fallback) ? fallback : ([...models.keys()][0] ?? null)
302
+ const swap = (slug: string): string | null => {
303
+ if (models.has(slug) || !usable) return null
304
+ warnings.push(` warning: ${label} names the model ${slug}, which this runtime does not have; it uses ${usable} here`)
305
+ return usable
306
+ }
307
+ const walk = (v: unknown, key: string): unknown => {
308
+ if (typeof v === 'string') {
309
+ const ref = parseRef(v)
310
+ if (ref?.kind === 'model') {
311
+ const next = swap(ref.name)
312
+ return next ? `model/${next}` : v
313
+ }
314
+ if (key === 'modelSlug') return swap(v) ?? v
315
+ return v
316
+ }
317
+ if (Array.isArray(v)) return v.map((x) => walk(x, key))
318
+ if (v && typeof v === 'object') return Object.fromEntries(Object.entries(v as Record<string, unknown>).map(([k, x]) => [k, walk(x, k)]))
319
+ return v
320
+ }
321
+ return walk(doc, '')
322
+ }
323
+
324
+ export async function resolveTarget(
325
+ project: LocalProject,
326
+ target: SyncTarget,
327
+ directory: () => Promise<Directory>,
328
+ workspaceId?: string,
329
+ organizationId?: string,
330
+ ): Promise<Resolved> {
331
+ if (target.kind !== 'agent' && target.kind !== 'workflow' && target.kind !== 'operating') return { ok: true, output: '' }
332
+ const path = join(project.root, target.file)
333
+ let doc: unknown
334
+ try {
335
+ doc = readDoc(path)
336
+ } catch (err) {
337
+ return { ok: false, output: `error [USAGE]: ${target.file} could not be read: ${(err as Error).message}` }
338
+ }
339
+ const modelSlugs = target.kind === 'agent' && (Array.isArray((doc as { modelConfigs?: unknown[] })?.modelConfigs) || typeof (doc as { config?: { voice?: { voiceSlug?: unknown } } })?.config?.voice?.voiceSlug === 'string')
340
+ const mcpServers = target.kind === 'agent' && Array.isArray((doc as { mcpServers?: unknown[] })?.mcpServers) && (doc as { mcpServers: unknown[] }).mcpServers.length > 0
341
+ const objectTools = target.kind === 'agent' && Array.isArray((doc as { objectBindings?: unknown[] })?.objectBindings) && (doc as { objectBindings: unknown[] }).objectBindings.length > 0
342
+ if (!kindsIn(doc).size && !modelSlugs && !mcpServers && !objectTools && !governedActionIds(doc).length && !(target.kind === 'workflow' && workflowNeedsLocalizing(doc))) return { ok: true, output: '', doc }
343
+ const dir = await directory()
344
+ const warnings: string[] = []
345
+ // Built-in connections the project has stand-ins for (`local-stand-ins.ts`).
346
+ const standIns = standInNames(project.root)
347
+ // A trigger on a stand-in, or on the platform-only analytics Plugin, answers no events here.
348
+ if (target.kind === 'workflow') warnings.push(...dropStandInTriggers(project.root, doc, new Set([...standIns, ...platformOnlyNames(project.root)])))
349
+ if (target.kind === 'agent' && doc && typeof doc === 'object') {
350
+ // The workspace's analytics stays on the platform: the Agent runs here without those tools.
351
+ warnings.push(...dropPlatformOnly(doc as Record<string, unknown>, platformOnlyNames(project.root)))
352
+ addStandInServers(project, doc as Record<string, unknown>, standIns)
353
+ }
354
+ await fillAgents(doc, dir)
355
+ doc = substituteModels(doc, dir, warnings, target.file)
356
+ if (target.kind === 'agent') substituteVoice(doc, await runtimeVoices(dir), warnings, target.file)
357
+ if (target.kind === 'workflow') await localizeWorkflow(doc, dir, project.root, warnings)
358
+ if (target.kind === 'agent' && doc && typeof doc === 'object') {
359
+ // A skill binding names the workspace the skill lives in: here, the runtime's own.
360
+ const bindings = (doc as { skills?: { workspaceBindings?: Array<Record<string, unknown>> } }).skills?.workspaceBindings
361
+ if (workspaceId) for (const b of bindings ?? []) b.workspaceId ??= workspaceId
362
+ await applyPluginTools(doc as Record<string, unknown>, dir, warnings)
363
+ if (mcpServers) fillMcpServerHeaders(project, doc as Record<string, unknown>)
364
+ // Its object tools: each definition made or brought up to date here, then bound by this runtime's id.
365
+ if (objectTools) warnings.push(...(await ensureObjectDefinitions(doc as Record<string, unknown>, dir, ownerCalls.get(dir) ?? null, target.file)))
366
+ for (const relation of OPTIONAL_RELATIONS) {
367
+ const found = relation(doc as Record<string, unknown>)
368
+ if (!found) continue
369
+ const kept = found.rows.filter((row) => {
370
+ const { unresolved } = toIds(row, dir)
371
+ if (unresolved.length) warnings.push(` warning: ${unresolved.map((u) => u.value).join(', ')} is not on this runtime; the Agent is applied without it`)
372
+ return !unresolved.length
373
+ })
374
+ found.set(kept)
375
+ }
376
+ }
377
+ const ids = toIds(doc, dir)
378
+ const resolved = ids.doc
379
+ let unresolved = ids.unresolved
380
+ // What nothing in the project provides (a built-in connection, a knowledge base not pulled, an
381
+ // object type the runtime leaves off) would be waited for forever: it is applied around instead,
382
+ // and each omission is an adaptation (`local-adapt.ts`). What the project holds but has not
383
+ // applied yet is still waited for, so the retry applies the file whole once it is there.
384
+ if (unresolved.length) {
385
+ const provides = projectProvides(project, organizationId)
386
+ const named = (u: (typeof unresolved)[number]) => !!u.kind && !isUuid(u.value)
387
+ if (!unresolved.some((u) => named(u) && provides(u.value))) {
388
+ const links = unresolved.filter((u) => u.link && named(u))
389
+ const adapted = plainLinks(resolved, links)
390
+ unresolved = unresolved.filter((u) => !links.includes(u))
391
+ if (target.kind === 'operating' && resolved && typeof resolved === 'object') {
392
+ adapted.unshift(...adaptOperating(resolved as Record<string, unknown>, unresolved.filter(named)))
393
+ unresolved = unresolved.filter((u) => !named(u))
394
+ }
395
+ if (target.kind === 'workflow' && resolved && typeof resolved === 'object') {
396
+ const inTrigger = unresolved.filter((u) => named(u) && /^trigger\.\d+(\.|$)/.test(u.path))
397
+ adapted.unshift(...dropTriggers(resolved as Record<string, unknown>, inTrigger))
398
+ unresolved = unresolved.filter((u) => !inTrigger.includes(u))
399
+ }
400
+ warnings.push(...adapted)
401
+ }
402
+ }
403
+ // A node still naming a governed Action by the platform's id, when this runtime's
404
+ // Actions are known and it is not one of them: say so here, not as the runtime's
405
+ // "does not exist in this workspace" about an id nobody can place.
406
+ const localActions = new Set(dir.action?.values() ?? [])
407
+ const byPlatformId = dir.action ? governedActionIds(doc).filter((a) => !localActions.has(a.id)) : []
408
+ if (unresolved.length || byPlatformId.length) {
409
+ const actions = unresolved.filter((u) => u.kind === 'action').length
410
+ const others = unresolved.length - actions
411
+ const missing = [...new Set(unresolved.flatMap((u) => (u.kind && !isUuid(u.value) ? [u.value.includes('/') ? u.value : `${u.kind}/${u.value}`] : [])))]
412
+ return {
413
+ ok: false,
414
+ missing,
415
+ ...(byPlatformId.length ? { unnamedActions: byPlatformId.length } : {}),
416
+ output: [
417
+ `error [UNRESOLVED_REFERENCE]: ${target.file} names what this runtime does not have`,
418
+ ...describeUnresolved(unresolved).map((l) => ` ${l}`),
419
+ ...byPlatformId.map((a) => ` ${a.path}: a governed Action named by its platform id (${a.id}), which this runtime does not have`),
420
+ ...(byPlatformId.length ? [' hint: pull the workflow again with an organization key connected (`frontera local sync connect`) to name its Actions'] : []),
421
+ ...(actions ? [` hint: governed Actions come with the Blueprint (pulled with an organization key) and are deployed on this runtime when it applies; ${actions === 1 ? 'this one is' : 'these are'} not in the project's blueprint/actions/`] : []),
422
+ ...(others ? [' hint: pull it from the workspace (`frontera local sync pull <kind/name>`) or add its file to the project'] : []),
423
+ ].join('\n'),
424
+ }
425
+ }
426
+ if (target.kind === 'operating') return { ok: true, doc: resolved, output: warnings.join('\n') }
427
+ if (!kindsIn(doc).size && !warnings.length && JSON.stringify(resolved) === JSON.stringify(readDoc(path))) return { ok: true, output: '', doc }
428
+ const copy = join('.frontera', 'local', 'resolved', target.file)
429
+ mkdirSync(dirname(join(project.root, copy)), { recursive: true })
430
+ writeFileSync(join(project.root, copy), /\.json$/.test(target.file) ? `${JSON.stringify(resolved, null, 2)}\n` : stringifyYaml(resolved, { lineWidth: 0 }))
431
+ return { ok: true, file: copy, output: warnings.join('\n') }
432
+ }
433
+
434
+ function parseJson<T>(output: string): T | null {
435
+ const start = output.search(/[[{]/)
436
+ if (start < 0) return null
437
+ try {
438
+ return JSON.parse(output.slice(start)) as T
439
+ } catch {
440
+ return null
441
+ }
442
+ }
443
+
444
+ /** How the runtime holds one object type of the Blueprint it was given. */
445
+ export interface ObjectBuild {
446
+ status: 'built' | 'adapted' | 'stubbed' | 'left-off'
447
+ adaptations?: string[]
448
+ }
449
+
450
+ export interface ResolvedBlueprint {
451
+ /** The runtime's copy, when the project's files needed changing; unset otherwise. */
452
+ dir?: string
453
+ warnings: string[]
454
+ /** Each object type as the runtime holds it, by apiName. */
455
+ objects: Record<string, ObjectBuild>
456
+ /** The governed Actions the runtime is given, with whether each runs here. */
457
+ actions: LocalAction[]
458
+ /** Actions left off the copy, with why: they act on what the runtime leaves off. */
459
+ leftOffActions: Record<string, string>
460
+ /** The Media Sets the runtime needs, empty, for the media-backed types it holds: API name → display name. */
461
+ mediaSets?: Record<string, string>
462
+ }
463
+
464
+ /**
465
+ * The Blueprint as this runtime can hold it, in a copy under
466
+ * `.frontera/local/resolved/blueprint`; the project's files stay as pulled.
467
+ * The rules are `objectTypeFit` and `actionFit` (`local-portability.ts`):
468
+ * - An object type backed by the platform's media files (a `media_reference`
469
+ * property) cannot be read from a local file dataset: it is left out, with
470
+ * the link types, metrics and governed Actions that name it.
471
+ * - An object type whose rows are read by markings, with no markings column
472
+ * named, relies on a column the platform's media datasets publish; over a
473
+ * local file dataset it reads with organization row access instead.
474
+ * - A calculated property is left off the type, and an Action that sets it.
475
+ * Each says so. `dir` is unset when nothing needed changing.
476
+ */
477
+ export function resolveBlueprint(project: LocalProject): ResolvedBlueprint {
478
+ const source = join(project.root, 'blueprint')
479
+ const types = join(source, 'object-types')
480
+ const none: ResolvedBlueprint = { warnings: [], objects: {}, actions: [], leftOffActions: {} }
481
+ if (!existsSync(types)) return none
482
+ const yamlIn = (dir: string) => (existsSync(join(source, dir)) ? readdirSync(join(source, dir)).filter((n) => /\.ya?ml$/.test(n) && !n.startsWith('.')) : [])
483
+ const read = (dir: string, name: string) => {
484
+ try {
485
+ return parseYaml(readFileSync(join(source, dir, name), 'utf8')) as Record<string, any> | null
486
+ } catch {
487
+ return null
488
+ }
489
+ }
490
+ const patched = new Map<string, string>()
491
+ const dropped = new Set<string>()
492
+ /** Calculated properties left off, as `Type.property`. */
493
+ const droppedFields = new Set<string>()
494
+ const removed: string[] = []
495
+ const warnings: string[] = []
496
+ const objects: Record<string, ObjectBuild> = {}
497
+ const mediaSets: Record<string, string> = {}
498
+ const hasLocalDataset = (dataset: string) => existsSync(join(project.root, 'datasets', `${dataset}.json`))
499
+ for (const name of yamlIn('object-types')) {
500
+ const doc = read('object-types', name)
501
+ if (!doc || typeof doc.apiName !== 'string') continue
502
+ const fit = localAdaptation(doc, hasLocalDataset)
503
+ const adaptations = classify(objectTypeNeeds(doc, hasLocalDataset)).adaptations
504
+ if (fit.leftOff) {
505
+ dropped.add(doc.apiName)
506
+ removed.push(join('object-types', name))
507
+ objects[doc.apiName] = { status: 'left-off', adaptations }
508
+ warnings.push(` warning: ${doc.apiName} is left off this runtime: it reads the platform's media files`)
509
+ continue
510
+ }
511
+ objects[doc.apiName] = fit.mediaStub ? { status: 'stubbed', adaptations } : adaptations.length ? { status: 'adapted', adaptations } : { status: 'built' }
512
+ let next = doc
513
+ // Over a Media Set's catalog: an empty Media Set of that name stands in, made before the apply.
514
+ // A property's set is the platform's id; the runtime derives its own from the dataset.
515
+ if (fit.mediaStub) {
516
+ mediaSets[fit.mediaStub] ??= readableName(fit.mediaStub)
517
+ const properties = (doc.properties as Array<Record<string, unknown>> | undefined) ?? []
518
+ next = { ...next, properties: properties.map(({ mediaSetId: _set, ...p }) => p) }
519
+ warnings.push(` warning: ${doc.apiName}’s files stay on the platform: an empty Media Set, ${fit.mediaStub}, stands in for them here`)
520
+ }
521
+ // A calculated property (a location from latitude and longitude) reads no column, and
522
+ // `blueprint apply` cannot create one yet: the runtime holds the type without it.
523
+ if (fit.calculated.length) {
524
+ const properties = (doc.properties as Array<{ calculation?: unknown }> | undefined) ?? []
525
+ next = { ...next, properties: properties.filter((p) => !p.calculation) }
526
+ for (const f of fit.calculated) droppedFields.add(`${doc.apiName}.${f}`)
527
+ warnings.push(` warning: ${doc.apiName}.${fit.calculated.join(`, ${doc.apiName}.`)} is left off this runtime: calculated fields are not applied locally yet`)
528
+ }
529
+ if (fit.readsEveryRow) {
530
+ next = { ...next, rowAccess: 'organization' }
531
+ warnings.push(` warning: ${doc.apiName ?? name} reads every row here: its markings come from the platform's dataset, and ${doc.backing?.dataset} has none`)
532
+ }
533
+ if (next !== doc) patched.set(join('object-types', name), stringifyYaml(next, { lineWidth: 0 }))
534
+ }
535
+ if (dropped.size) {
536
+ for (const name of yamlIn('link-types')) {
537
+ const doc = read('link-types', name)
538
+ if (dropped.has(doc?.from?.objectType) || dropped.has(doc?.to?.objectType)) removed.push(join('link-types', name))
539
+ }
540
+ for (const name of yamlIn('metrics')) {
541
+ if (dropped.has(read('metrics', name)?.objectType)) removed.push(join('metrics', name))
542
+ }
543
+ }
544
+ const actions: LocalAction[] = []
545
+ const leftOffActions: Record<string, string> = {}
546
+ for (const name of yamlIn('actions')) {
547
+ const doc = read('actions', name)
548
+ if (!doc || typeof doc.apiName !== 'string') continue
549
+ const fit = actionFit(doc)
550
+ const changes = (Array.isArray(doc.businessOutcomes) ? doc.businessOutcomes : []).flatMap((o: Record<string, any>) => (Array.isArray(o?.expectedChanges) ? o.expectedChanges : [])) as Array<Record<string, any>>
551
+ const inputs = (Array.isArray(doc.inputs) ? doc.inputs : []) as Array<Record<string, any>>
552
+ const touches = [fit.subject, ...changes.map((c) => c?.objectType), ...inputs.map((i) => i?.valueType?.objectType)].filter((t): t is string => typeof t === 'string')
553
+ const offType = touches.find((t) => dropped.has(t))
554
+ const offField = changes.map((c) => `${c?.objectType ?? fit.subject}.${c?.property}`).find((f) => droppedFields.has(f))
555
+ if (offType || offField) {
556
+ leftOffActions[doc.apiName] = offType ? `it acts on ${offType}, which reads the platform’s media files` : `it sets ${offField}, a calculated field the runtime leaves off`
557
+ removed.push(join('actions', name))
558
+ warnings.push(` warning: governed Action ${doc.apiName} is left off this runtime: ${leftOffActions[doc.apiName]}`)
559
+ continue
560
+ }
561
+ const capability = doc.governance?.invokeCapability
562
+ actions.push({ apiName: doc.apiName, fit, ...(typeof capability === 'string' ? { invokeCapability: capability } : {}) })
563
+ }
564
+ const out: ResolvedBlueprint = { warnings, objects, actions, leftOffActions, ...(Object.keys(mediaSets).length ? { mediaSets } : {}) }
565
+ if (!patched.size && !removed.length) return out
566
+ const copy = join('.frontera', 'local', 'resolved', 'blueprint')
567
+ rmSync(join(project.root, copy), { recursive: true, force: true })
568
+ cpSync(source, join(project.root, copy), { recursive: true })
569
+ for (const [rel, text] of patched) writeFileSync(join(project.root, copy, rel), text)
570
+ for (const rel of removed) rmSync(join(project.root, copy, rel), { force: true })
571
+ return { ...out, dir: copy }
572
+ }
573
+
574
+ /**
575
+ * The properties each object type lets a governed Action write
576
+ * (`backing.editable`), for the types those Actions act on. `blueprint apply`
577
+ * leaves backings to `bind`, so `local dev` declares them itself
578
+ * (`blueprint editable`) before an Action's write path is built.
579
+ */
580
+ export function editableSets(dir: string, subjects: ReadonlySet<string>): Map<string, string[]> {
581
+ const out = new Map<string, string[]>()
582
+ const at = join(dir, 'object-types')
583
+ if (!existsSync(at)) return out
584
+ for (const name of readdirSync(at)) {
585
+ if (!/\.ya?ml$/.test(name) || name.startsWith('.')) continue
586
+ try {
587
+ const doc = parseYaml(readFileSync(join(at, name), 'utf8')) as { apiName?: unknown; backing?: { editable?: unknown } } | null
588
+ const editable = doc?.backing?.editable
589
+ if (typeof doc?.apiName === 'string' && subjects.has(doc.apiName) && Array.isArray(editable) && editable.length) out.set(doc.apiName, editable.map(String).sort())
590
+ } catch {
591
+ // A file that does not parse is refused by the apply itself.
592
+ }
593
+ }
594
+ return out
595
+ }
596
+
597
+ /**
598
+ * A copy of a Blueprint folder without some governed Actions, under
599
+ * `.frontera/local/resolved/blueprint-without`: applied with `--prune`, it
600
+ * takes them out of the runtime's draft so the full folder can author them
601
+ * again under new ids. Returns the copy's absolute path.
602
+ */
603
+ export function blueprintWithout(project: LocalProject, dir: string, actions: string[]): string {
604
+ const copy = join(project.root, '.frontera', 'local', 'resolved', 'blueprint-without')
605
+ rmSync(copy, { recursive: true, force: true })
606
+ cpSync(dir, copy, { recursive: true })
607
+ const at = join(copy, 'actions')
608
+ if (existsSync(at)) {
609
+ for (const name of readdirSync(at)) {
610
+ if (!/\.ya?ml$/.test(name)) continue
611
+ try {
612
+ const doc = parseYaml(readFileSync(join(at, name), 'utf8')) as { apiName?: unknown } | null
613
+ if (typeof doc?.apiName === 'string' && actions.includes(doc.apiName)) rmSync(join(at, name), { force: true })
614
+ } catch {
615
+ // A file that does not parse is applied as it is, and refused there.
616
+ }
617
+ }
618
+ }
619
+ return copy
620
+ }
621
+
622
+ /** The settings a knowledge base's file holds that decide how it chunks, searches and reads (`knowledge.json`). */
623
+ const KNOWLEDGE_SETTINGS = ['retrieval', 'chunker', 'chunkSize', 'chunkOverlap', 'topK', 'similarityThreshold', 'hybridConfig', 'extractorConfig', 'ocrConfig'] as const
624
+
625
+ /** The files `local dev` uploads into a knowledge base: those the runtime ingests. */
626
+ export const KNOWLEDGE_DOCUMENT = /\.(md|markdown|txt|pdf|docx|html)$/i
627
+
628
+ /**
629
+ * A knowledge base's settings as the runtime can hold them. A model it names
630
+ * (the reranker, the extractor's, OCR's) that the runtime's catalog does not
631
+ * have is left off here, so documents are read and searched without that
632
+ * step instead of failing; each says so. `models` is the catalog, or null
633
+ * when it could not be read (the settings then go as they are).
634
+ */
635
+ export function knowledgeSettings(doc: Record<string, unknown>, models: ReadonlySet<string> | null, label: string): { settings: Record<string, unknown>; warnings: string[] } {
636
+ const settings: Record<string, unknown> = {}
637
+ for (const key of KNOWLEDGE_SETTINGS) if (doc[key] !== undefined && doc[key] !== null) settings[key] = doc[key]
638
+ // The platform keeps the threshold as a decimal, which its list sends as a string ("0.7"); the update takes numbers.
639
+ for (const key of ['chunkSize', 'chunkOverlap', 'topK', 'similarityThreshold']) {
640
+ if (typeof settings[key] === 'string' && Number.isFinite(Number(settings[key]))) settings[key] = Number(settings[key])
641
+ }
642
+ const warnings: string[] = []
643
+ const has = (model: unknown) => typeof model !== 'string' || !model.trim() || !models || models.has(model) || models.has(model.split('/').pop()!)
644
+ const hybrid = settings.hybridConfig as Record<string, unknown> | undefined
645
+ if (hybrid?.rerank === true && !has(hybrid.rerankerModel)) {
646
+ settings.hybridConfig = { ...hybrid, rerank: false }
647
+ warnings.push(` warning: Knowledge base ${label} searches without reranking here: its reranker ${String(hybrid.rerankerModel)} isn’t on this runtime`)
648
+ }
649
+ for (const [key, what] of [['extractorConfig', 'extraction'], ['ocrConfig', 'OCR']] as const) {
650
+ const step = settings[key] as Record<string, unknown> | undefined
651
+ if (step?.enabled === true && !has(step.model)) {
652
+ settings[key] = { ...step, enabled: false }
653
+ warnings.push(` warning: Knowledge base ${label} reads documents without ${what} here: its model ${String(step.model)} isn’t on this runtime`)
654
+ }
655
+ }
656
+ return { settings, warnings }
657
+ }
658
+
659
+ /** The runtime's model catalog, by slug and by provider/model id; null when it could not be read. */
660
+ async function localModels(local: { serviceUrl: string; creds: LocalCredentials }): Promise<Set<string> | null> {
661
+ try {
662
+ const listed = await syncClient(local.serviceUrl, local.creds.orgKey).get('/v1/config/models/catalog')
663
+ const out = new Set<string>()
664
+ for (const m of rows<{ slug?: string; modelId?: string; provider?: string }>(listed)) {
665
+ for (const v of [m.slug, m.modelId, m.provider && m.modelId ? `${m.provider}/${m.modelId}` : undefined]) if (v) out.add(v)
666
+ }
667
+ return out
668
+ } catch {
669
+ return null
670
+ }
671
+ }
672
+
673
+ /**
674
+ * A knowledge base: created when the runtime has none by its name, given the
675
+ * file's settings (retrieval mode, chunking, top-K and threshold, hybrid
676
+ * search and reranking, extraction and OCR), then the folder's documents the
677
+ * base does not hold yet are uploaded.
678
+ */
679
+ export async function applyKnowledge(
680
+ project: LocalProject,
681
+ target: Extract<SyncTarget, { kind: 'knowledge' }>,
682
+ profiles: SyncProfiles,
683
+ run: (args: string[]) => Promise<RunResult>,
684
+ local?: { serviceUrl: string; creds: LocalCredentials },
685
+ ): Promise<RunResult> {
686
+ let doc: Record<string, unknown> & { name?: string; description?: string }
687
+ try {
688
+ doc = JSON.parse(readFileSync(join(project.root, target.file), 'utf8')) as Record<string, unknown> & { name?: string; description?: string }
689
+ } catch (err) {
690
+ return { ok: false, output: `error [USAGE]: ${target.file} is not valid JSON: ${(err as Error).message}` }
691
+ }
692
+ const name = doc.name || target.name
693
+ const p = ['--profile', profiles.workspace]
694
+ const list = async () => parseJson<Array<{ id?: string; name: string }>>((await run(['knowledge', 'list', '--json', ...p])).output) ?? []
695
+ const listed = await run(['knowledge', 'list', '--json', ...p])
696
+ if (!listed.ok) return listed
697
+ let bases = parseJson<Array<{ id?: string; name: string }>>(listed.output) ?? []
698
+ if (!bases.some((b) => b.name === name)) {
699
+ const created = await run(['knowledge', 'create', name, ...(doc.description ? ['--description', doc.description] : []), ...p])
700
+ if (!created.ok) return created
701
+ bases = await list()
702
+ }
703
+ const notes: string[] = []
704
+ const id = bases.find((b) => b.name === name)?.id
705
+ if (local?.creds.workspaceKey && id) {
706
+ const { settings, warnings } = knowledgeSettings(doc, await localModels(local), name)
707
+ notes.push(...warnings)
708
+ if (Object.keys(settings).length) {
709
+ const failed = await fetch(new URL(`/v1/workspace-knowledge/${encodeURIComponent(id)}`, local.serviceUrl), {
710
+ method: 'PUT',
711
+ headers: { authorization: `Bearer ${local.creds.workspaceKey}`, 'content-type': 'application/json' },
712
+ body: JSON.stringify(settings),
713
+ })
714
+ .then(async (res) => (res.ok ? null : `${res.status} ${(await res.text()).slice(0, 200)}`))
715
+ .catch((err: Error) => err.message)
716
+ if (failed) notes.push(` warning: Knowledge base ${name} keeps the runtime’s own retrieval settings: its settings were refused (${failed})`)
717
+ }
718
+ } else if (KNOWLEDGE_SETTINGS.some((k) => doc[k] !== undefined)) {
719
+ notes.push(` warning: Knowledge base ${name} keeps the runtime’s own retrieval settings: the runtime has no workspace key yet`)
720
+ }
721
+ const finished = (r: RunResult): RunResult => (notes.length ? { ...r, output: [...notes, r.output.trim()].filter(Boolean).join('\n') } : r)
722
+ // Its quality checks, once its documents are there: they name documents by path.
723
+ const done = async (r: RunResult): Promise<RunResult> => {
724
+ if (!r.ok || !existsSync(join(project.root, 'knowledge', target.name, QUALITY_CHECKS_FILE))) return finished(r)
725
+ if (!local?.creds.workspaceKey || !id) {
726
+ notes.push(` warning: Knowledge base ${name}’s quality checks wait until the runtime has a workspace key`)
727
+ return finished(r)
728
+ }
729
+ const checked = await applyKnowledgeChecks(project, target.name, name, id, local)
730
+ return finished({ ...r, output: [r.output.trim(), ...checked].filter(Boolean).join('\n') })
731
+ }
732
+ // The documents, the knowledge base's folders as subfolders (`Policies/Returns/terms.pdf`).
733
+ const { documentPath, documentPaths, folderPaths, splitDocumentPath } = await import('./local-knowledge-documents')
734
+ const documents = documentPaths(project.root, target.name)
735
+ if (!documents.length) return await done({ ok: true, output: '' })
736
+ const sources = await run(['knowledge', 'sources', name, '--json', ...p])
737
+ const listedSources = parseJson<Array<{ fileName?: string; folderId?: string | null }>>(sources.output) ?? []
738
+ const key = local?.creds.workspaceKey
739
+ const http = async (method: string, path: string, body?: FormData | Record<string, unknown>) => {
740
+ const res = await fetch(new URL(path, local!.serviceUrl), {
741
+ method,
742
+ headers: { authorization: `Bearer ${key}`, ...(body && !(body instanceof FormData) ? { 'content-type': 'application/json' } : {}) },
743
+ ...(body ? { body: body instanceof FormData ? body : JSON.stringify(body) } : {}),
744
+ })
745
+ const text = await res.text()
746
+ if (!res.ok) throw new Error(`${res.status} ${text.slice(0, 200)}`)
747
+ const parsed = text ? (JSON.parse(text) as { data?: unknown }) : null
748
+ return (parsed?.data ?? parsed) as Record<string, unknown> | null
749
+ }
750
+ const nested = documents.some((d) => d.includes('/'))
751
+ const folders = nested && key && id && listedSources.some((s) => s.folderId)
752
+ ? folderPaths((((await http('GET', `/v1/workspace-knowledge/${encodeURIComponent(id)}/folders`).catch(() => null))?.folders ?? []) as Array<{ id: string; parentId?: string | null; name: string }>))
753
+ : new Map<string, string>()
754
+ const have = new Set(listedSources.map((s) => documentPath(s.folderId ? folders.get(s.folderId) : null, s.fileName ?? '')))
755
+ const fresh = documents.filter((f) => !have.has(f))
756
+ if (!fresh.length) return await done({ ok: true, output: '' })
757
+ const top = fresh.filter((f) => !f.includes('/'))
758
+ const filed = fresh.filter((f) => f.includes('/'))
759
+ const lines: string[] = []
760
+ if (top.length) {
761
+ const uploaded = await run(['knowledge', 'upload', name, ...top.map((f) => join(dirname(target.file), f)), ...p])
762
+ if (!uploaded.ok) return await done(uploaded)
763
+ if (uploaded.output.trim()) lines.push(uploaded.output.trim())
764
+ }
765
+ if (filed.length && (!key || !id)) {
766
+ notes.push(` warning: Knowledge base ${name}: ${filed.length} document${filed.length === 1 ? ' in a folder waits' : 's in folders wait'} until the runtime has a workspace key`)
767
+ } else if (filed.length) {
768
+ // Each folder made as the uploader makes a dropped directory's (the same name under the same parent is the same folder).
769
+ const made = new Map<string, string>()
770
+ const folderFor = async (path: string): Promise<string> => {
771
+ let parentId: string | null = null
772
+ let at = ''
773
+ for (const segment of path.split('/')) {
774
+ at = at ? `${at}/${segment}` : segment
775
+ const known = made.get(at)
776
+ if (known) {
777
+ parentId = known
778
+ continue
779
+ }
780
+ const created = await http('POST', `/v1/workspace-knowledge/${encodeURIComponent(id!)}/folders`, { name: segment, parentId })
781
+ parentId = String((created?.folder as { id?: unknown } | undefined)?.id ?? '')
782
+ if (!parentId) throw new Error('the runtime did not return the folder')
783
+ made.set(at, parentId)
784
+ }
785
+ return parentId!
786
+ }
787
+ for (const path of filed) {
788
+ const { folder, fileName } = splitDocumentPath(path)
789
+ try {
790
+ const form = new FormData()
791
+ form.append('file', new File([readFileSync(join(project.root, dirname(target.file), path))], fileName))
792
+ form.append('folderId', await folderFor(folder!))
793
+ await http('POST', `/v1/workspace-knowledge/${encodeURIComponent(id!)}/sources`, form)
794
+ lines.push(` uploaded ${path}`)
795
+ } catch (err) {
796
+ notes.push(` warning: Knowledge base ${name}: ${path} wasn’t uploaded (${(err as Error).message})`)
797
+ }
798
+ }
799
+ }
800
+ return await done({ ok: true, output: lines.join('\n') })
801
+ }
802
+
803
+ /**
804
+ * A knowledge base's quality checks on the runtime (`local-quality-checks.ts`),
805
+ * their documents by this runtime's ids. Returns the lines for the build.
806
+ */
807
+ async function applyKnowledgeChecks(project: LocalProject, folder: string, name: string, id: string, local: { serviceUrl: string; creds: LocalCredentials }): Promise<string[]> {
808
+ const rel = join('knowledge', folder, QUALITY_CHECKS_FILE)
809
+ let checks
810
+ try {
811
+ checks = readQualityChecks(readFileSync(join(project.root, rel), 'utf8'))
812
+ } catch (err) {
813
+ return [` warning: ${rel} doesn’t parse, so its quality checks weren’t applied: ${(err as Error).message.split('\n')[0]}`]
814
+ }
815
+ const call = async (method: string, path: string, body?: unknown) => {
816
+ const res = await fetch(new URL(path, local.serviceUrl), {
817
+ method,
818
+ headers: { authorization: `Bearer ${local.creds.workspaceKey}`, ...(body === undefined ? {} : { 'content-type': 'application/json' }) },
819
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
820
+ })
821
+ const text = await res.text()
822
+ let parsed: unknown = text
823
+ try {
824
+ parsed = text ? JSON.parse(text) : null
825
+ } catch {
826
+ // Kept as text.
827
+ }
828
+ return { status: res.status, body: parsed }
829
+ }
830
+ const { readFolders } = await import('./local-knowledge-documents')
831
+ const sources = await call('GET', `/v1/workspace-knowledge/${encodeURIComponent(id)}/sources`)
832
+ const byPath = new Map([...sourcePaths(sourceRows(sources.body), await readFolders({ get: async (path: string) => (await call('GET', path)).body }, id))].map(([sourceId, path]) => [path, sourceId] as const))
833
+ const applied = await applyQualityChecks(id, name, checks, byPath, call)
834
+ return applied.lines
835
+ }
836
+
837
+ /** What a Plugin may do, as its file's `policy` holds it (`pluginPolicy` in `local-sync-remote.ts`). */
838
+ interface PluginPolicyDoc {
839
+ enabled?: boolean
840
+ actions?: string
841
+ newTools?: string
842
+ tools?: Record<string, { enabled?: boolean; requiresApproval?: boolean; paramConstraints?: Record<string, unknown> }>
843
+ }
844
+
845
+ /**
846
+ * An MCP Plugin: installed under the file's name, or its configuration
847
+ * replaced when it is installed already; then what it may do.
848
+ *
849
+ * A pulled file carries the workspace's `policy` (on or off, its Action
850
+ * policy, what a new tool starts as, each tool's own setting), and the
851
+ * runtime is given the same, so an Agent cannot call a tool here that the
852
+ * workspace keeps read-only or behind an approval. The policy is not part of
853
+ * the connection's configuration: the runtime is given the file without it.
854
+ * A file written by hand, with no policy, has its tools enabled, as a local
855
+ * runtime has one person trying them.
856
+ */
857
+ export async function applyPlugin(
858
+ project: LocalProject,
859
+ target: Extract<SyncTarget, { kind: 'plugin' }>,
860
+ profiles: SyncProfiles,
861
+ run: (args: string[]) => Promise<RunResult>,
862
+ local?: { serviceUrl: string; creds: LocalCredentials },
863
+ ): Promise<RunResult> {
864
+ let doc: Record<string, unknown>
865
+ try {
866
+ doc = JSON.parse(readFileSync(join(project.root, target.file), 'utf8')) as Record<string, unknown>
867
+ } catch (err) {
868
+ return { ok: false, output: `error [USAGE]: ${target.file} is not valid JSON: ${(err as Error).message}` }
869
+ }
870
+ // Its description and icon are the Plugin's own, not connection settings: set after it is there.
871
+ const { policy, description, icon, ...declared } = doc
872
+ const p = ['--profile', profiles.workspace]
873
+ const find = async () => {
874
+ const listed = await run(['plugin', 'list', '--json', ...p])
875
+ const rows = parseJson<Array<{ install?: { id: string; installName: string; config?: Record<string, unknown> | null } }>>(listed.output) ?? []
876
+ return rows.find((r) => r.install?.installName === target.name)?.install
877
+ }
878
+ const existing = await find()
879
+ let id = existing?.id
880
+ // Settings declared empty (`""`, secrets) are filled with a reference to this runtime's secret
881
+ // named after them (`local-secrets.ts`), resolved when the Plugin connects, so setting the secret
882
+ // later needs no new apply. One that already has a value on this install and no such secret is
883
+ // sent empty instead, which keeps that value: the platform keeps a stored secret an update leaves blank.
884
+ const inputs = pluginSecretInputs(target.name, declared)
885
+ const have = inputs.length && local ? ((await localSecretNames(local.serviceUrl, local.creds)) ?? new Set<string>()) : new Set<string>()
886
+ const stored = (existing?.config ?? {}) as Record<string, unknown>
887
+ const storedHeaders = (stored.headers && typeof stored.headers === 'object' ? stored.headers : {}) as Record<string, unknown>
888
+ const hasStored = (i: (typeof inputs)[number]) => !!(i.header ? storedHeaders[i.field] : stored[i.field])
889
+ const filled = inputs.filter((i) => have.has(i.secret) || !hasStored(i))
890
+ const config = fillPluginSecrets(declared, filled)
891
+ const needs = inputs
892
+ .filter((i) => !have.has(i.secret) && !hasStored(i))
893
+ .map((i) => needsValueLine(i.secret, `Plugin ${target.name}, ${i.header ? 'header ' : ''}${i.field}`))
894
+ let file = target.file
895
+ if (policy !== undefined || filled.length || description !== undefined || icon !== undefined) {
896
+ file = join('.frontera', 'local', 'resolved', target.file)
897
+ mkdirSync(dirname(join(project.root, file)), { recursive: true })
898
+ writeFileSync(join(project.root, file), `${JSON.stringify(config, null, 2)}\n`)
899
+ }
900
+ const step = id ? await run(['plugin', 'configure', id, '--file', file, ...p]) : await run(['plugin', 'add', 'mcp', '--name', target.name, '--file', file, ...p])
901
+ if (!step.ok) return step
902
+ id ??= (await find())?.id
903
+ if (!id) return { ok: false, output: `error [NOT_FOUND]: ${target.name} was added but is not listed` }
904
+ const about = local && (description !== undefined || icon !== undefined) ? await applyPluginAbout(project, local, id, target.name, description, icon) : []
905
+ if (!policy || typeof policy !== 'object') {
906
+ const open = await run(['plugin', 'policy', id, '--enable', '--actions', 'allow_all', '--new-capability', 'enabled', ...p])
907
+ return open.ok ? { ok: true, output: [step.output.trim(), ...needs, ...about].filter(Boolean).join('\n') } : { ok: true, output: ` warning: ${target.name} is installed, but its tools could not be enabled:\n${open.output}` }
908
+ }
909
+ const want = policy as PluginPolicyDoc
910
+ const notes: string[] = [...needs, ...about]
911
+ const set = await run(['plugin', 'policy', id, want.enabled === false ? '--disable' : '--enable', '--actions', want.actions ?? 'read_only', '--new-capability', want.newTools ?? 'disabled', ...p])
912
+ if (!set.ok) notes.push(` warning: ${target.name}’s policy could not be set as the workspace has it: ${oneLine(applyErrorText(set.output))}`)
913
+ const tools = Object.entries(want.tools ?? {})
914
+ if (tools.length) {
915
+ const listed = await run(['capability', 'available', id, '--json', ...p])
916
+ const here = new Map((parseJson<Array<{ id?: string; name?: string }>>(listed.output) ?? []).flatMap((r) => (r.id && r.name ? [[r.name, r.id] as const] : [])))
917
+ for (const [name, setting] of tools) {
918
+ const capability = here.get(name)
919
+ if (!capability) {
920
+ notes.push(` warning: ${target.name} has no tool ${name} here, so its setting from the workspace is not applied`)
921
+ continue
922
+ }
923
+ const failed = local ? await setToolPolicy(local, id, capability, setting) : 'the runtime has no owner session'
924
+ if (failed) notes.push(` warning: ${target.name}’s tool ${name} could not be set as the workspace has it: ${failed}`)
925
+ }
926
+ }
927
+ return { ok: true, output: [step.output.trim(), ...notes].filter(Boolean).join('\n') }
928
+ }
929
+
930
+ /**
931
+ * An Agent's MCP servers with the headers its Plugin has on this runtime.
932
+ *
933
+ * A draft Agent (every local one) runs its MCP servers from its own
934
+ * `mcpServers` list, not from the Plugin installs as a published one does, and
935
+ * a pull leaves their headers out. So each server named like a Plugin of the
936
+ * project takes that Plugin file's headers: a value the file gives, or, for
937
+ * one declared empty, a reference to the local secret that fills the Plugin's
938
+ * header (`local-secrets.ts`), which the runtime resolves when it connects
939
+ * (empty until the secret is set; the Plugin's apply says it needs a value).
940
+ * Headers the server already lists stay unless the Plugin gives one.
941
+ */
942
+ export function fillMcpServerHeaders(project: LocalProject, doc: Record<string, unknown>): void {
943
+ const servers = Array.isArray(doc.mcpServers) ? (doc.mcpServers as Array<Record<string, unknown>>) : []
944
+ for (const server of servers) {
945
+ const name = typeof server?.name === 'string' ? server.name : null
946
+ const file = name ? join(project.root, 'mcp', `${name}.plugin.json`) : null
947
+ if (!name || !file || !existsSync(file)) continue
948
+ let plugin: Record<string, unknown>
949
+ try {
950
+ plugin = JSON.parse(readFileSync(file, 'utf8')) as Record<string, unknown>
951
+ } catch {
952
+ continue
953
+ }
954
+ const declared = plugin.headers && typeof plugin.headers === 'object' && !Array.isArray(plugin.headers) ? (plugin.headers as Record<string, unknown>) : {}
955
+ const headers: Record<string, string> = {}
956
+ for (const [h, value] of Object.entries(declared)) {
957
+ if (typeof value === 'string' && value !== '') {
958
+ headers[h] = value
959
+ continue
960
+ }
961
+ if (!NON_SECRET_HEADERS.has(h.toLowerCase())) headers[h] = `{{vault.${pluginSecretName(name, h)}}}`
962
+ }
963
+ if (Object.keys(headers).length) server.headers = { ...((server.headers as Record<string, unknown> | undefined) ?? {}), ...headers }
964
+ }
965
+ }
966
+
967
+ /** One tool's own setting on an install. The route takes a person's session: the owner's, as Studio has it. */
968
+ const ICON_MIME: Record<string, string> = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', webp: 'image/webp', gif: 'image/gif', svg: 'image/svg+xml' }
969
+
970
+ /**
971
+ * A Plugin's description and icon on the runtime, as its file has them:
972
+ * the icon (a project file beside the Plugin's, `mcp/<name>.icon.png`) is
973
+ * uploaded as the workspace's Plugin icons are, then both are set on the
974
+ * install. Returns warning lines for what could not be set; never fails the
975
+ * Plugin, which works without them.
976
+ */
977
+ async function applyPluginAbout(project: LocalProject, local: { serviceUrl: string; creds: LocalCredentials }, installId: string, name: string, description: unknown, icon: unknown): Promise<string[]> {
978
+ const { creds } = local
979
+ if (!creds.session) return [` warning: ${name}’s description and icon are not set: the runtime has no owner session; restart it`]
980
+ const headers = { authorization: `Bearer ${creds.session}`, 'x-org-id': creds.organizationId, 'x-workspace-id': creds.workspaceId }
981
+ const out: string[] = []
982
+ const patch: Record<string, unknown> = {}
983
+ if (description === null || typeof description === 'string') patch.description = typeof description === 'string' && description.trim() ? description.trim() : null
984
+ if (typeof icon === 'string' && icon) {
985
+ const at = join(project.root, icon)
986
+ const ext = icon.split('.').pop()?.toLowerCase() ?? ''
987
+ if (!at.startsWith(project.root) || !existsSync(at) || !ICON_MIME[ext]) out.push(` warning: ${name}’s icon ${icon} is not an image file in the project, so it has none here`)
988
+ else {
989
+ try {
990
+ const form = new FormData()
991
+ form.set('file', new File([readFileSync(at)], icon.split('/').pop()!, { type: ICON_MIME[ext] }))
992
+ form.set('purpose', 'app-icon')
993
+ const res = await fetch(new URL('/v1/upload', local.serviceUrl), { method: 'POST', headers, body: form })
994
+ const body = (await res.json().catch(() => null)) as { data?: { storagePath?: string } } | null
995
+ if (!res.ok || !body?.data?.storagePath) throw new Error(`upload answered ${res.status}`)
996
+ patch.iconPath = body.data.storagePath
997
+ } catch (err) {
998
+ out.push(` warning: ${name}’s icon could not be set: ${(err as Error).message}`)
999
+ }
1000
+ }
1001
+ } else if (icon === null) patch.iconPath = null
1002
+ if (!Object.keys(patch).length) return out
1003
+ try {
1004
+ const res = await fetch(new URL(`/v1/apps/${encodeURIComponent(installId)}`, local.serviceUrl), { method: 'PUT', headers: { ...headers, 'content-type': 'application/json' }, body: JSON.stringify(patch) })
1005
+ if (!res.ok) out.push(` warning: ${name}’s description and icon could not be set: ${res.status} ${(await res.text()).slice(0, 200)}`)
1006
+ } catch (err) {
1007
+ out.push(` warning: ${name}’s description and icon could not be set: ${(err as Error).message}`)
1008
+ }
1009
+ return out
1010
+ }
1011
+
1012
+ async function setToolPolicy(local: { serviceUrl: string; creds: LocalCredentials }, installId: string, capabilityId: string, setting: NonNullable<PluginPolicyDoc['tools']>[string]): Promise<string | null> {
1013
+ if (!local.creds.session) return 'the runtime has no owner session; restart it'
1014
+ try {
1015
+ const res = await fetch(new URL(`/v1/workspace/installs/${encodeURIComponent(installId)}/capability-policies/${encodeURIComponent(capabilityId)}`, local.serviceUrl), {
1016
+ method: 'PATCH',
1017
+ headers: { authorization: `Bearer ${local.creds.session}`, 'x-org-id': local.creds.organizationId, 'x-workspace-id': local.creds.workspaceId, 'content-type': 'application/json' },
1018
+ body: JSON.stringify({ enabled: setting.enabled ?? null, requiresApprovalOverride: setting.requiresApproval ?? null, paramConstraints: setting.paramConstraints ?? null }),
1019
+ })
1020
+ return res.ok ? null : `${res.status} ${(await res.text()).slice(0, 200)}`
1021
+ } catch (err) {
1022
+ return (err as Error).message
1023
+ }
1024
+ }
1025
+
1026
+ /**
1027
+ * Promote a workflow's latest draft to live on the local runtime. Returns the
1028
+ * version promoted, or null when there was none or it was refused.
1029
+ */
1030
+ export async function promoteWorkflow(serviceUrl: string, creds: LocalCredentials, slug: string): Promise<number | null> {
1031
+ if (!creds.workspaceKey) return null
1032
+ const call = (path: string, method: string) =>
1033
+ fetch(new URL(path, serviceUrl), { method, headers: { authorization: `Bearer ${creds.workspaceKey}`, 'content-type': 'application/json' }, body: method === 'POST' ? '{}' : undefined })
1034
+ const res = await call(`/v1/workflows/${encodeURIComponent(slug)}`, 'GET')
1035
+ if (!res.ok) return null
1036
+ const n = ((await res.json()) as { data?: { latestDraftNumber?: number | null } }).data?.latestDraftNumber
1037
+ if (!n) return null
1038
+ const promoted = await call(`/v1/workflows/${encodeURIComponent(slug)}/versions/${n}/promote`, 'POST')
1039
+ return promoted.ok ? n : null
1040
+ }
1041
+
1042
+ /** The project's object type files by API name, parsed; unreadable ones are left out. */
1043
+ function projectObjectTypes(root: string): Map<string, Record<string, unknown>> {
1044
+ const dir = join(root, 'blueprint', 'object-types')
1045
+ const out = new Map<string, Record<string, unknown>>()
1046
+ if (!existsSync(dir)) return out
1047
+ for (const name of readdirSync(dir)) {
1048
+ if (!/\.(ya?ml|json)$/.test(name) || name.startsWith('.')) continue
1049
+ try {
1050
+ const doc = parseYaml(readFileSync(join(dir, name), 'utf8')) as Record<string, unknown> | null
1051
+ if (doc && typeof doc.apiName === 'string') out.set(doc.apiName, doc)
1052
+ } catch {
1053
+ // A file that does not parse names nothing here; `blueprint apply` reports it.
1054
+ }
1055
+ }
1056
+ return out
1057
+ }
1058
+
1059
+ /**
1060
+ * What an Agent may use (`agents/<slug>/bindings.json`): each Function and
1061
+ * workflow it names is bound to the Agent with the settings in the file, and
1062
+ * each object type in its `blueprint` list is granted to it (its Context and
1063
+ * data, `local-agent-grants.ts`). Bindings already there are updated, not
1064
+ * duplicated; one the runtime cannot make (the Function is not deployed yet)
1065
+ * is reported and the rest go on.
1066
+ */
1067
+ export async function applyBindings(project: LocalProject, target: Extract<SyncTarget, { kind: 'bindings' }>, serviceUrl: string, creds: LocalCredentials): Promise<RunResult> {
1068
+ let doc: { functions?: Array<Record<string, unknown>>; workflows?: Array<Record<string, unknown>>; blueprint?: unknown }
1069
+ try {
1070
+ doc = JSON.parse(readFileSync(join(project.root, target.file), 'utf8'))
1071
+ } catch (err) {
1072
+ return { ok: false, output: `error [USAGE]: ${target.file} is not valid JSON: ${(err as Error).message}` }
1073
+ }
1074
+ if (!creds.workspaceKey) return { ok: false, output: 'error [LOCAL_NOT_SEEDED]: the runtime has no workspace key yet; restart it' }
1075
+ const call = (path: string, method: string, body?: Record<string, unknown>) =>
1076
+ fetch(new URL(path, serviceUrl), {
1077
+ method,
1078
+ headers: { authorization: `Bearer ${creds.workspaceKey}`, 'content-type': 'application/json' },
1079
+ body: body === undefined ? undefined : JSON.stringify(body),
1080
+ })
1081
+ const put = async (path: string, body: Record<string, unknown>) => {
1082
+ const res = await call(path, 'PUT', body)
1083
+ return res.ok ? null : `${res.status} ${(await res.text()).slice(0, 200)}`
1084
+ }
1085
+ const notes: string[] = []
1086
+ // An Agent runs only a workflow's live version, and local dev pushes drafts: on this
1087
+ // one-person runtime, a workflow an Agent may start is promoted so the binding holds.
1088
+ const promote = async (slug: string): Promise<boolean> => {
1089
+ const n = await promoteWorkflow(serviceUrl, creds, slug)
1090
+ if (n) notes.push(` warning: promoted workflow ${slug} v${n} so ${target.slug} can start it`)
1091
+ return n !== null
1092
+ }
1093
+ const failures: string[] = []
1094
+ const settings = (row: Record<string, unknown>, keys: string[]) => Object.fromEntries(keys.filter((k) => row[k] !== undefined).map((k) => [k, row[k]]))
1095
+ for (const row of doc.functions ?? []) {
1096
+ const ref = typeof row.function === 'string' ? parseRef(row.function) : null
1097
+ if (!ref || ref.kind !== 'function') continue
1098
+ const failed = await put(`/v1/functions/${encodeURIComponent(ref.name)}/bindings/${encodeURIComponent(target.slug)}`, settings(row, ['enabled', 'requiresApproval', 'waitSeconds']))
1099
+ if (failed) failures.push(` ${row.function}: ${failed}`)
1100
+ }
1101
+ for (const row of doc.workflows ?? []) {
1102
+ const ref = typeof row.workflow === 'string' ? parseRef(row.workflow) : null
1103
+ if (!ref || ref.kind !== 'workflow') continue
1104
+ const path = `/v1/workflows/${encodeURIComponent(ref.name)}/bindings/${encodeURIComponent(target.slug)}`
1105
+ const body = settings(row, ['enabled', 'access', 'requiresApproval', 'waitSeconds'])
1106
+ let failed = await put(path, body)
1107
+ if (failed && /no live version/i.test(failed) && (await promote(ref.name))) failed = await put(path, body)
1108
+ if (failed) failures.push(` ${row.workflow}: ${failed}`)
1109
+ }
1110
+ // Context and data: the object types it may read, granted with the organization key.
1111
+ // One the pull could not read is assumed as read on every object type the project
1112
+ // holds and the runtime keeps (an adaptation the build names, from the file).
1113
+ const unread = !Array.isArray(doc.blueprint) && (doc as { blueprintUnread?: unknown }).blueprintUnread === true
1114
+ if (Array.isArray(doc.blueprint) || unread) {
1115
+ const types = projectObjectTypes(project.root)
1116
+ const keptHere = [...types].filter(([, type]) => !objectTypeFit(type, () => true).leftOff).map(([apiName]) => apiName).sort()
1117
+ const granted = await applyAgentGrants({
1118
+ slug: target.slug,
1119
+ grants: unread ? keptHere.map((apiName) => ({ objectType: `objectType/${apiName}`, permissions: ['read'] })) : (doc.blueprint as AgentGrant[]),
1120
+ serviceUrl,
1121
+ creds,
1122
+ leftOff: (apiName) => {
1123
+ const type = types.get(apiName)
1124
+ return type && objectTypeFit(type, () => true).leftOff ? 'it reads the platform’s media files' : null
1125
+ },
1126
+ inProject: (apiName) => types.has(apiName),
1127
+ })
1128
+ failures.push(...granted.failures)
1129
+ notes.push(...granted.notes)
1130
+ }
1131
+ return failures.length ? { ok: false, output: [`error [BINDING_FAILED]: ${target.slug} could not be given every tool`, ...failures, ...notes].join('\n') } : { ok: true, output: notes.join('\n') }
1132
+ }