@frontera-sdk/cli 1.52.27 → 1.52.29
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.
- package/package.json +5 -4
- package/src/commands/local/data.ts +60 -0
- package/src/commands/local/dev.ts +361 -18
- package/src/commands/local/index-commands.ts +11 -3
- package/src/commands/local/init.ts +88 -7
- package/src/commands/local/sync-request.ts +750 -0
- package/src/commands/local/sync.ts +941 -0
- package/src/commands/skill/bundle-commands.ts +9 -6
- package/src/errors.ts +4 -1
- package/src/exit.ts +3 -0
- package/src/flag-help.ts +48 -0
- package/src/local/local-actions.ts +306 -0
- package/src/local/local-adapt.ts +216 -0
- package/src/local/local-agent-files.ts +9 -2
- package/src/local/local-agent-grants.ts +125 -0
- package/src/local/local-agent-name.ts +71 -0
- package/src/local/local-applied.ts +334 -3
- package/src/local/local-apps.ts +156 -0
- package/src/local/local-blueprint-outline.ts +465 -0
- package/src/local/local-brand.ts +123 -0
- package/src/local/local-build-summary.ts +106 -0
- package/src/local/local-chat-apps.ts +170 -0
- package/src/local/local-dev-references.ts +1132 -0
- package/src/local/local-dev-sync.ts +171 -7
- package/src/local/local-docs.ts +53 -5
- package/src/local/local-env.ts +2 -0
- package/src/local/local-function-draft.ts +33 -0
- package/src/local/local-function-shared.ts +65 -0
- package/src/local/local-function-state.ts +34 -0
- package/src/local/local-knowledge-documents.ts +302 -0
- package/src/local/local-lessons.ts +278 -0
- package/src/local/local-library.ts +124 -0
- package/src/local/local-media-sets.ts +31 -0
- package/src/local/local-object-sets.ts +181 -0
- package/src/local/local-objects.ts +159 -0
- package/src/local/local-operating.ts +9 -5
- package/src/local/local-org-settings.ts +118 -0
- package/src/local/local-portability.ts +346 -0
- package/src/local/local-project.ts +6 -2
- package/src/local/local-quality-checks.ts +171 -0
- package/src/local/local-scaffold.ts +14 -11
- package/src/local/local-schemas.ts +33 -2
- package/src/local/local-secrets.ts +306 -0
- package/src/local/local-stand-ins.ts +442 -0
- package/src/local/local-studio-server.ts +23 -2
- package/src/local/local-sync-base.ts +237 -0
- package/src/local/local-sync-client.ts +93 -0
- package/src/local/local-sync-link.ts +122 -0
- package/src/local/local-sync-merge.ts +395 -0
- package/src/local/local-sync-names.ts +169 -0
- package/src/local/local-sync-plan.ts +717 -0
- package/src/local/local-sync-push.ts +986 -0
- package/src/local/local-sync-refs.ts +221 -0
- package/src/local/local-sync-remote.ts +1482 -0
- package/src/local/local-sync-snapshot.ts +526 -0
- package/src/local/local-synthetic-data.ts +374 -0
- package/src/local/local-test-runner.ts +172 -14
- package/src/local/local-themes.ts +201 -0
- 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
|
+
}
|