@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.
- 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,1482 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process'
|
|
2
|
+
import { mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { join, posix, relative } from 'node:path'
|
|
5
|
+
|
|
6
|
+
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'
|
|
7
|
+
|
|
8
|
+
import { toFiles } from '../blueprint/projection'
|
|
9
|
+
import { serialize } from '../blueprint/tree'
|
|
10
|
+
import { assetFilename, toSkillMd, type SkillDoc } from '../commands/skill/bundle-commands'
|
|
11
|
+
import { CliError } from '../errors'
|
|
12
|
+
import { editorFunctionModule } from './local-function-draft'
|
|
13
|
+
import { slugify } from './local-project'
|
|
14
|
+
import { MASKED, NON_SECRET_HEADERS, pluginSecretInputs, SECRETISH } from './local-secrets'
|
|
15
|
+
import type { SyncClient } from './local-sync-client'
|
|
16
|
+
import type { RemoteItem, SyncKind } from './local-sync-plan'
|
|
17
|
+
import { actionFit, portabilityOf, type BuiltInKind } from './local-portability'
|
|
18
|
+
import { readThemes, type ThemeRow } from './local-themes'
|
|
19
|
+
import { readChatApps, type ChatAppRow } from './local-chat-apps'
|
|
20
|
+
import { readApps, type AppRow as CustomAppRow } from './local-apps'
|
|
21
|
+
import { readBrand, type BrandRow } from './local-brand'
|
|
22
|
+
import { readLibrary, type LibraryRow } from './local-library'
|
|
23
|
+
import { isSharedFunctionFile } from './local-function-shared'
|
|
24
|
+
import { attachDefinitions, type ObjectDefinitionRow } from './local-objects'
|
|
25
|
+
import { checksFromFixtures, fixtureRows, qualityChecksPath, qualityChecksText, sourcePaths, sourceRows } from './local-quality-checks'
|
|
26
|
+
import { readableName } from './local-sync-names'
|
|
27
|
+
import { governedActionIds, invert, itemRef, referencesIn, toNames, type Directory, type RefKind } from './local-sync-refs'
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Reading a workspace as the files a project holds. Each kind lists what the
|
|
31
|
+
* workspace has, reads each item's live version (a draft only when asked),
|
|
32
|
+
* rewrites ids to `kind/name` with the workspace's own directory, and lays it
|
|
33
|
+
* out the way `frontera local dev` applies it. The same directory builder
|
|
34
|
+
* serves the local runtime when `local dev` turns names back into ids.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
export interface SyncSource {
|
|
38
|
+
/** The workspace key's client. */
|
|
39
|
+
workspace: SyncClient
|
|
40
|
+
/** The organization key's client, when one was given. */
|
|
41
|
+
organization?: SyncClient
|
|
42
|
+
workspaceId: string
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Envelopes come single (`{data}`) or double (`{data:{data}}`); lists are what is inside. */
|
|
46
|
+
export function rows<T>(body: unknown): T[] {
|
|
47
|
+
let v = body
|
|
48
|
+
for (let i = 0; i < 3 && v && !Array.isArray(v) && typeof v === 'object' && 'data' in (v as object); i++) v = (v as { data: unknown }).data
|
|
49
|
+
// Paged lists put their rows under `items`.
|
|
50
|
+
if (v && !Array.isArray(v) && typeof v === 'object' && Array.isArray((v as { items?: unknown }).items)) v = (v as { items: unknown[] }).items
|
|
51
|
+
return Array.isArray(v) ? (v as T[]) : []
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function one<T>(body: unknown): T {
|
|
55
|
+
let v = body
|
|
56
|
+
for (let i = 0; i < 3 && v && typeof v === 'object' && !Array.isArray(v) && Object.keys(v as object).length <= 3 && 'data' in (v as object); i++) v = (v as { data: unknown }).data
|
|
57
|
+
return v as T
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** A resource's name as a reference can hold it: knowledge bases are named in words. */
|
|
61
|
+
export function refName(name: string): string {
|
|
62
|
+
return /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name) ? name : slugify(name) || 'unnamed'
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function quietly<T>(warnings: string[], what: string, read: () => Promise<T>, empty: T): Promise<T> {
|
|
66
|
+
try {
|
|
67
|
+
return await read()
|
|
68
|
+
} catch (err) {
|
|
69
|
+
warnings.push(`${what}: ${(err as Error).message}`)
|
|
70
|
+
return empty
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
interface AgentRow {
|
|
75
|
+
id: string
|
|
76
|
+
agentId: string
|
|
77
|
+
name: string
|
|
78
|
+
description?: string | null
|
|
79
|
+
currentVersionId?: string | null
|
|
80
|
+
operatingConfig?: unknown
|
|
81
|
+
/** Its Context and data, where the platform's `/v1/sync/agents` gives it (`agent-context`). */
|
|
82
|
+
contextAndData?: Array<{ objectTypeApiName?: string; permissions?: string[] }>
|
|
83
|
+
}
|
|
84
|
+
interface WorkflowRow { id: string; slug: string; displayName?: string | null; liveVersion?: number | null; latestDraftNumber?: number | null; archivedAt?: string | null; enabled?: boolean | null }
|
|
85
|
+
interface FunctionRow { id: string; slug: string; description?: string | null; liveVersion?: number | null; enabled?: boolean | null }
|
|
86
|
+
interface KnowledgeRow {
|
|
87
|
+
id: string
|
|
88
|
+
name: string
|
|
89
|
+
description?: string | null
|
|
90
|
+
retrieval?: unknown
|
|
91
|
+
chunker?: string | null
|
|
92
|
+
chunkSize?: number | null
|
|
93
|
+
chunkOverlap?: number | null
|
|
94
|
+
similarityThreshold?: number | null
|
|
95
|
+
topK?: number | null
|
|
96
|
+
hybridConfig?: unknown
|
|
97
|
+
extractorConfig?: unknown
|
|
98
|
+
ocrConfig?: unknown
|
|
99
|
+
}
|
|
100
|
+
interface AppRow {
|
|
101
|
+
install?: { id: string; installName: string; config?: Record<string, unknown> | null; enabled?: boolean | null; actionPolicy?: string | null; newCapabilityDefault?: string | null; description?: string | null; iconUrl?: string | null }
|
|
102
|
+
catalog?: { kind?: string; name?: string; displayName?: string }
|
|
103
|
+
}
|
|
104
|
+
/** One tool of an installed Plugin, with its own setting on the install when it has one (newer platforms). */
|
|
105
|
+
interface ToolRow { id: string; name: string; kind?: string; policy?: { enabled?: boolean | null; requiresApprovalOverride?: boolean | null; paramConstraints?: Record<string, unknown> | null } | null }
|
|
106
|
+
|
|
107
|
+
/** Each installed Plugin's tools, by install id: read once, for the Plugin files and the Agents' tool settings. */
|
|
108
|
+
export type PluginTools = Map<string, ToolRow[]>
|
|
109
|
+
|
|
110
|
+
async function readPluginTools(source: SyncSource, apps: AppRow[], warnings: string[]): Promise<PluginTools> {
|
|
111
|
+
const out: PluginTools = new Map()
|
|
112
|
+
for (const a of apps) {
|
|
113
|
+
if (!a.install) continue
|
|
114
|
+
const install = a.install
|
|
115
|
+
out.set(install.id, await quietly(warnings, `Plugin ${install.installName} tools`, async () => rows<ToolRow>(await source.workspace.get(`/v1/apps/${encodeURIComponent(install.id)}/capabilities`)), []))
|
|
116
|
+
}
|
|
117
|
+
return out
|
|
118
|
+
}
|
|
119
|
+
interface SkillRow { id: string; name: string; displayName?: string | null }
|
|
120
|
+
interface ActionRow { id: string; apiName: string }
|
|
121
|
+
/** A saved object set, as `/v1/sync/object-sets` lists it: a definition over one object type, or a static list of its records by key. */
|
|
122
|
+
export interface ObjectSetRow {
|
|
123
|
+
id: string
|
|
124
|
+
name: string
|
|
125
|
+
description?: string | null
|
|
126
|
+
kind: 'dynamic' | 'static'
|
|
127
|
+
objectType: string
|
|
128
|
+
definition?: unknown
|
|
129
|
+
staticPks?: Array<string | number> | null
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Each saved set's name in a reference (`objectSet/open-cases`): its name as
|
|
134
|
+
* a reference can hold it, numbered when two sets would share one. The same
|
|
135
|
+
* rule on the workspace and on the local runtime, so a name means one set.
|
|
136
|
+
*/
|
|
137
|
+
export function objectSetNames(sets: ObjectSetRow[]): Map<string, string> {
|
|
138
|
+
const out = new Map<string, string>()
|
|
139
|
+
const taken = new Set<string>()
|
|
140
|
+
for (const set of [...sets].sort((a, b) => a.name.localeCompare(b.name) || a.id.localeCompare(b.id))) {
|
|
141
|
+
const base = refName(set.name)
|
|
142
|
+
let name = base
|
|
143
|
+
for (let n = 2; taken.has(name); n++) name = `${base}-${n}`
|
|
144
|
+
taken.add(name)
|
|
145
|
+
out.set(set.id, name)
|
|
146
|
+
}
|
|
147
|
+
return out
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Governed Actions by id and API name, so a workflow node that runs one names
|
|
152
|
+
* it `action/<apiName>`. Only the names: the Actions themselves are not pulled.
|
|
153
|
+
* Reading Action definitions is organization-grain (a workspace key is refused
|
|
154
|
+
* them), so this asks with the organization key. The draft holds every Action,
|
|
155
|
+
* published or not; the published list adds any the draft has since dropped.
|
|
156
|
+
*/
|
|
157
|
+
async function listActions(org: SyncClient): Promise<ActionRow[]> {
|
|
158
|
+
const pick = (row: unknown): ActionRow[] => {
|
|
159
|
+
const d = ((row as { definition?: unknown } | null)?.definition ?? row) as Partial<ActionRow> | null
|
|
160
|
+
return d && typeof d.id === 'string' && typeof d.apiName === 'string' ? [{ id: d.id, apiName: d.apiName }] : []
|
|
161
|
+
}
|
|
162
|
+
const draft = rows<unknown>(await org.get('/v1/blueprint/actions?view=draft')).flatMap(pick)
|
|
163
|
+
const published = rows<unknown>(await org.get('/v1/blueprint/actions').catch(() => null)).flatMap(pick)
|
|
164
|
+
return [...new Map([...published, ...draft].map((a) => [a.id, a])).values()]
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* The workspace's Agents with those not yet published, which `/v1/config/agents`
|
|
169
|
+
* leaves out; a platform without `/v1/sync/agents` answers with the published ones.
|
|
170
|
+
*/
|
|
171
|
+
async function listAgents(ws: SyncSource['workspace']): Promise<AgentRow[]> {
|
|
172
|
+
const all = await ws.find('/v1/sync/agents')
|
|
173
|
+
return rows<AgentRow>(all ?? (await ws.get('/v1/config/agents')))
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Every list a reference may point into, name → id, for one runtime. */
|
|
177
|
+
export async function buildDirectory(source: SyncSource, warnings: string[] = []): Promise<{ directory: Directory; lists: Lists }> {
|
|
178
|
+
const ws = source.workspace
|
|
179
|
+
const [agents, workflows, functions, knowledge, apps, skills, objectTypes, models, actions, objectSets, objectDefinitions] = await Promise.all([
|
|
180
|
+
quietly(warnings, 'Agents', () => listAgents(ws), []),
|
|
181
|
+
quietly(warnings, 'Workflows', async () => rows<WorkflowRow>(await ws.get('/v1/workflows')).filter((w) => !w.archivedAt), []),
|
|
182
|
+
quietly(warnings, 'Functions', async () => rows<FunctionRow>(await ws.get('/v1/functions')), []),
|
|
183
|
+
quietly(warnings, 'Knowledge', async () => rows<KnowledgeRow>(await ws.get(`/v1/workspace-knowledge?workspaceId=${encodeURIComponent(source.workspaceId)}`)), []),
|
|
184
|
+
quietly(warnings, 'Plugins', async () => rows<AppRow>(await ws.get('/v1/apps')), []),
|
|
185
|
+
quietly(warnings, 'Skills', async () => rows<SkillRow>(await ws.get('/v1/config/workspace-skills')), []),
|
|
186
|
+
quietly(warnings, 'Blueprint object types', async () => rows<{ id: string; apiName: string }>(await ws.get('/v1/blueprint/object-types')), []),
|
|
187
|
+
// The model catalog is organization-grain: a workspace key reads its names only, where the platform offers them.
|
|
188
|
+
source.organization
|
|
189
|
+
? quietly(warnings, 'Models', async () => rows<{ id: string; slug: string }>(await source.organization!.get('/v1/config/models/catalog')), [])
|
|
190
|
+
: quietly(warnings, 'Models', async () => rows<{ id: string; slug: string }>(await ws.find<unknown>('/v1/sync/models')), []),
|
|
191
|
+
// So are governed Actions' definitions: without the organization key they are not listed.
|
|
192
|
+
source.organization ? quietly<ActionRow[] | null>(warnings, 'Governed Actions', () => listActions(source.organization!), null) : Promise.resolve(null),
|
|
193
|
+
// A platform without the route has none to name: absent, as for Actions.
|
|
194
|
+
quietly<ObjectSetRow[] | null>(warnings, 'Saved object sets', async () => {
|
|
195
|
+
const found = await ws.find<unknown>('/v1/sync/object-sets')
|
|
196
|
+
return found === null ? null : rows<ObjectSetRow>(found)
|
|
197
|
+
}, null),
|
|
198
|
+
// The object definitions an Agent's object tools work on; null on a platform that doesn't list them.
|
|
199
|
+
quietly<ObjectDefinitionRow[] | null>(warnings, 'Object definitions', async () => {
|
|
200
|
+
const found = await ws.find<unknown>('/v1/sync/object-definitions')
|
|
201
|
+
return found === null ? null : rows<ObjectDefinitionRow>(found)
|
|
202
|
+
}, null),
|
|
203
|
+
])
|
|
204
|
+
const setNames = objectSetNames(objectSets ?? [])
|
|
205
|
+
const directory: Directory = {
|
|
206
|
+
agent: new Map(agents.map((a) => [a.agentId, a.id])),
|
|
207
|
+
workflow: new Map(workflows.map((w) => [w.slug, w.id])),
|
|
208
|
+
function: new Map(functions.map((f) => [f.slug, f.id])),
|
|
209
|
+
knowledge: new Map(knowledge.map((k) => [refName(k.name), k.id])),
|
|
210
|
+
plugin: new Map(apps.flatMap((a) => (a.install ? [[a.install.installName, a.install.id] as [string, string]] : []))),
|
|
211
|
+
skill: new Map(skills.map((s) => [s.name, s.id])),
|
|
212
|
+
objectType: new Map(objectTypes.map((o) => [o.apiName, o.id])),
|
|
213
|
+
model: new Map(models.map((m) => [m.slug, m.id])),
|
|
214
|
+
// Absent, not empty, when they could not be listed, so "none" and "unknown" differ.
|
|
215
|
+
...(actions ? { action: new Map(actions.map((a) => [a.apiName, a.id])) } : {}),
|
|
216
|
+
...(objectSets ? { objectSet: new Map((objectSets ?? []).map((o) => [setNames.get(o.id)!, o.id])) } : {}),
|
|
217
|
+
...(objectDefinitions ? { object: new Map(objectDefinitions.map((o) => [o.slug, o.id])) } : {}),
|
|
218
|
+
}
|
|
219
|
+
return { directory, lists: { agents, workflows, functions, knowledge, apps, skills, objectSets: objectSets ?? [], objectDefinitions } }
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export interface Lists {
|
|
223
|
+
agents: AgentRow[]
|
|
224
|
+
workflows: WorkflowRow[]
|
|
225
|
+
functions: FunctionRow[]
|
|
226
|
+
knowledge: KnowledgeRow[]
|
|
227
|
+
apps: AppRow[]
|
|
228
|
+
skills: SkillRow[]
|
|
229
|
+
/** The saved object sets on the workspace's Blueprint. */
|
|
230
|
+
objectSets?: ObjectSetRow[]
|
|
231
|
+
/** The object definitions an Agent's object tools work on; null when the platform doesn't list them. */
|
|
232
|
+
objectDefinitions?: ObjectDefinitionRow[] | null
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export interface ReadOptions {
|
|
236
|
+
/** Items to read as their draft instead of the live version, by ref. */
|
|
237
|
+
drafts?: Set<string>
|
|
238
|
+
/** Only these kinds. */
|
|
239
|
+
kinds?: Set<SyncKind>
|
|
240
|
+
/** `organization` reads the whole Blueprint with the organization key. */
|
|
241
|
+
blueprint?: 'workspace' | 'organization'
|
|
242
|
+
/**
|
|
243
|
+
* Only these refs, for the kinds read item by item: a fetch reads what the
|
|
244
|
+
* sync index says changed. The Blueprint and evaluations are read whole.
|
|
245
|
+
*/
|
|
246
|
+
only?: Set<string>
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const wanted = (opts: ReadOptions, ref: string) => !opts.only || opts.only.has(ref)
|
|
250
|
+
|
|
251
|
+
const json = (value: unknown) => `${JSON.stringify(value, null, 2)}\n`
|
|
252
|
+
|
|
253
|
+
function item(kind: SyncKind, name: string, title: string, files: Record<string, string>, extra: Partial<RemoteItem> = {}): RemoteItem {
|
|
254
|
+
return { ref: `${kind}/${name}`, kind, name, title, files, unresolved: [], notes: [], ...extra }
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Row bookkeeping a relation row carries on the platform, meaningless in a project. */
|
|
258
|
+
const ROW_KEYS = new Set(['id', 'agentId', 'workspaceId', 'orgId', 'createdAt', 'updatedAt', 'createdBy', 'updatedBy'])
|
|
259
|
+
|
|
260
|
+
function stripRow(row: Record<string, unknown>): Record<string, unknown> {
|
|
261
|
+
return Object.fromEntries(Object.entries(row).filter(([k, v]) => !ROW_KEYS.has(k) && v !== null && v !== undefined))
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* The settings an Agent's file keeps. A key left out falls back to the local
|
|
266
|
+
* default when applied, never to the workspace's value, so each that changes
|
|
267
|
+
* how the Agent behaves is here: its tool permission rules (by role), its
|
|
268
|
+
* Function guardrails, the model modes its utilities use, its audit and QA
|
|
269
|
+
* evaluator settings, and its work-item statuses and labels.
|
|
270
|
+
*/
|
|
271
|
+
const AGENT_CONFIG_KEYS = [
|
|
272
|
+
'name', 'description', 'avatarSeed', 'avatarConfig', 'defaultMode', 'utilityMode', 'utilityOverrides', 'agentType', 'agentModeEnabled', 'disableWebBrowsing',
|
|
273
|
+
'plugins', 'features', 'memory', 'voice', 'compression', 'streaming', 'upload', 'planning', 'delegation', 'modelRouting', 'toolPhasing', 'stopConditions',
|
|
274
|
+
'maxCostPerRun', 'permissions', 'functionGuardrails', 'audit', 'harness', 'statuses', 'defaultStatus', 'defaultLabels',
|
|
275
|
+
]
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* An Agent's snapshot cut down to what a project file holds: its settings,
|
|
279
|
+
* instructions, model modes, and the Knowledge, Skills and Plugins it uses by
|
|
280
|
+
* name. Its behavior settings go to `operating.json`; channels and
|
|
281
|
+
* applied packs belong to the platform.
|
|
282
|
+
*/
|
|
283
|
+
export function projectAgent(snapshot: Record<string, unknown>, notes: string[], tools?: { apps: AppRow[]; tools: PluginTools }): Record<string, unknown> {
|
|
284
|
+
const config = (snapshot.config ?? {}) as Record<string, unknown>
|
|
285
|
+
const out: Record<string, unknown> = {
|
|
286
|
+
config: Object.fromEntries(AGENT_CONFIG_KEYS.flatMap((k) => {
|
|
287
|
+
const v = config[k]
|
|
288
|
+
if (v === null || v === undefined) return []
|
|
289
|
+
if (typeof v === 'object' && !Array.isArray(v) && Object.keys(v as object).length === 0) return []
|
|
290
|
+
return [[k, v]]
|
|
291
|
+
})),
|
|
292
|
+
}
|
|
293
|
+
const list = (key: string) => (Array.isArray(snapshot[key]) ? (snapshot[key] as Array<Record<string, unknown>>) : [])
|
|
294
|
+
if (list('modelConfigs').length) out.modelConfigs = list('modelConfigs').map(stripRow)
|
|
295
|
+
if (list('prompts').length) out.prompts = list('prompts').map(stripRow)
|
|
296
|
+
const skills = ((snapshot.skills as { workspaceBindings?: Array<Record<string, unknown>> } | undefined)?.workspaceBindings ?? []).map(stripRow)
|
|
297
|
+
if (skills.length) out.skills = { workspaceBindings: skills }
|
|
298
|
+
if (list('knowledgeAttachments').length) out.knowledgeAttachments = list('knowledgeAttachments').map(stripRow)
|
|
299
|
+
// `functions` (the legacy `agent_automations` rows) is left out: nothing runs them any more. The
|
|
300
|
+
// Functions an Agent may call are its Function bindings, in `bindings.json`.
|
|
301
|
+
if (list('appInstalls').length) out.appInstalls = list('appInstalls').map(stripRow)
|
|
302
|
+
if (list('objectBindings').length) out.objectBindings = list('objectBindings').map(stripRow)
|
|
303
|
+
if (list('mcpServers').length) {
|
|
304
|
+
out.mcpServers = list('mcpServers').map((s) => {
|
|
305
|
+
const { headers, ...rest } = stripRow(s)
|
|
306
|
+
if (headers && Object.keys(headers as object).length) notes.push(`MCP server ${String(s.name)}: its headers were left out; locally they come from its Plugin, mcp/${String(s.name)}.plugin.json`)
|
|
307
|
+
return rest
|
|
308
|
+
})
|
|
309
|
+
}
|
|
310
|
+
// Its tool settings on each Plugin (a sensitive tool granted to it, an approval it overrides),
|
|
311
|
+
// by Plugin and tool name: capability ids are the install's own.
|
|
312
|
+
if (list('appCapabilities').length) {
|
|
313
|
+
const named = tools ? toolNames(tools) : new Map<string, { plugin: string; tool: string }>()
|
|
314
|
+
const rows = list('appCapabilities').flatMap((row) => {
|
|
315
|
+
const at = named.get(String(row.capabilityId))
|
|
316
|
+
if (!at) return []
|
|
317
|
+
const { capabilityId: _id, ...rest } = stripRow(row)
|
|
318
|
+
return [{ plugin: at.plugin, tool: at.tool, ...rest }]
|
|
319
|
+
})
|
|
320
|
+
if (rows.length) out.pluginTools = rows.sort((a, b) => `${a.plugin}/${a.tool}`.localeCompare(`${b.plugin}/${b.tool}`))
|
|
321
|
+
const lost = list('appCapabilities').length - rows.length
|
|
322
|
+
if (lost) notes.push(`${lost} Plugin tool setting${lost === 1 ? '' : 's'} could not be named and stay${lost === 1 ? 's' : ''} on the platform; locally the Agent uses ${lost === 1 ? 'that tool' : 'those tools'} as the Plugin allows`)
|
|
323
|
+
}
|
|
324
|
+
if (list('channelBindings').length) notes.push('Channels stay on the platform')
|
|
325
|
+
// Which resource packs set it up is the platform's record; what they brought comes as its own items.
|
|
326
|
+
const packs = list('appliedPacks').length
|
|
327
|
+
if (packs) notes.push(`Set up from ${packs} resource pack${packs === 1 ? '' : 's'} on the platform; the skills and Plugins ${packs === 1 ? 'it' : 'they'} brought come as the project's own`)
|
|
328
|
+
return out
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** Capability id → its Plugin (`plugin/<install name>`) and tool name. */
|
|
332
|
+
function toolNames(t: { apps: AppRow[]; tools: PluginTools }): Map<string, { plugin: string; tool: string }> {
|
|
333
|
+
const out = new Map<string, { plugin: string; tool: string }>()
|
|
334
|
+
for (const a of t.apps) {
|
|
335
|
+
if (!a.install) continue
|
|
336
|
+
for (const tool of t.tools.get(a.install.id) ?? []) out.set(tool.id, { plugin: `plugin/${a.install.installName}`, tool: tool.name })
|
|
337
|
+
}
|
|
338
|
+
return out
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* The Agent list leaves out Agents never published; an id a document names
|
|
343
|
+
* that the directory does not know is looked up as an Agent before it is
|
|
344
|
+
* reported, so a workflow's step that runs a draft Agent is still named.
|
|
345
|
+
*/
|
|
346
|
+
async function nameUnknownAgents(source: SyncSource, directory: Directory, docs: Array<{ doc: unknown }>): Promise<void> {
|
|
347
|
+
directory.agent ??= new Map()
|
|
348
|
+
const known = new Set([...directory.agent.values()])
|
|
349
|
+
for (const { doc } of docs) {
|
|
350
|
+
for (const u of toNames(doc, directory).unresolved) {
|
|
351
|
+
if (known.has(u.value)) continue
|
|
352
|
+
known.add(u.value)
|
|
353
|
+
const found = await source.workspace.find<{ agentId?: string }>(`/v1/config/agents/${u.value}`).catch(() => null)
|
|
354
|
+
if (found?.agentId) directory.agent.set(found.agentId, u.value)
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function finish(it: RemoteItem, directory: Directory, docs: Array<{ path: string; doc: unknown; format: 'json' | 'yaml' }>): RemoteItem {
|
|
360
|
+
const self = it.ref
|
|
361
|
+
const requires = new Set<string>()
|
|
362
|
+
for (const { path, doc, format } of docs) {
|
|
363
|
+
const { doc: named, unresolved } = toNames(doc, directory)
|
|
364
|
+
it.unresolved.push(...unresolved.map((u) => ({ ...u, path: `${path}: ${u.path}` })))
|
|
365
|
+
for (const r of referencesIn(named)) {
|
|
366
|
+
// An object definition travels inside the Agent's file: nothing to bring along.
|
|
367
|
+
if (r.kind === 'object') continue
|
|
368
|
+
const ref = itemRef(r.kind, r.name)
|
|
369
|
+
if (ref !== self) requires.add(ref)
|
|
370
|
+
}
|
|
371
|
+
it.files[path] = format === 'json' ? json(named) : stringifyYaml(named, { lineWidth: 0 })
|
|
372
|
+
}
|
|
373
|
+
it.requires = [...requires].sort()
|
|
374
|
+
return it
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
interface BindingRow { agentSlug?: string; enabled?: boolean; requiresApproval?: boolean; waitSeconds?: number; access?: string }
|
|
378
|
+
|
|
379
|
+
export interface AgentBindings {
|
|
380
|
+
functions: Array<{ function: string; enabled?: boolean; requiresApproval?: boolean; waitSeconds?: number }>
|
|
381
|
+
workflows: Array<{ workflow: string; access?: string; enabled?: boolean; requiresApproval?: boolean; waitSeconds?: number }>
|
|
382
|
+
/**
|
|
383
|
+
* Its Context and data: the object types it may read through its Blueprint
|
|
384
|
+
* tools (`objectType/Incident`), with the permissions granted. Absent when
|
|
385
|
+
* it has none, or they could not be read (no organization key).
|
|
386
|
+
*/
|
|
387
|
+
blueprint?: Array<{ objectType: string; permissions: string[] }>
|
|
388
|
+
/**
|
|
389
|
+
* Its Context and data could not be read on the platform with the project's
|
|
390
|
+
* keys. `local dev` then gives it read access to the object types the
|
|
391
|
+
* project holds, as an adaptation, until a pull can read it. Never sent
|
|
392
|
+
* back as grants: a change request leaves the Agent's grants as they are.
|
|
393
|
+
*/
|
|
394
|
+
blueprintUnread?: true
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
type Grants = NonNullable<AgentBindings['blueprint']>
|
|
398
|
+
|
|
399
|
+
/** What a pull knows of an Agent's Context and data: its grants, or why they could not be read. */
|
|
400
|
+
export type AgentContext = { grants: Grants; note?: string } | { unread: string }
|
|
401
|
+
|
|
402
|
+
const asGrants = (granted: Array<{ objectTypeApiName?: string; permissions?: string[] }>): Grants =>
|
|
403
|
+
granted
|
|
404
|
+
.flatMap((g) => (typeof g.objectTypeApiName === 'string' ? [{ objectType: `objectType/${g.objectTypeApiName}`, permissions: [...(g.permissions?.length ? g.permissions : ['read'])].sort() }] : []))
|
|
405
|
+
.sort((a, b) => a.objectType.localeCompare(b.objectType))
|
|
406
|
+
|
|
407
|
+
const STEWARD_FIX = 'An organization key made by a Blueprint Steward with organization update reads it, and so does the workspace key on a newer platform'
|
|
408
|
+
|
|
409
|
+
/** "Insufficient permissions" in words a person can act on. */
|
|
410
|
+
function unreadWhy(err: unknown): string {
|
|
411
|
+
const code = err instanceof CliError ? err.code : null
|
|
412
|
+
if (code === 'FORBIDDEN' || code === 'UNAUTHORIZED') return `the organization key may not read it. ${STEWARD_FIX}`
|
|
413
|
+
return `the platform did not answer for it (${(err as Error).message}). Fetch again`
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* An Agent's Context and data (its Blueprint grants), by object type name,
|
|
418
|
+
* read the first way the platform allows:
|
|
419
|
+
* 1. the workspace's Agent list (`/v1/sync/agents`), which gives them to the
|
|
420
|
+
* workspace key, read-only;
|
|
421
|
+
* 2. on an older platform, the Blueprint's grant route with the organization
|
|
422
|
+
* key, which only a Steward with organization update may read;
|
|
423
|
+
* 3. the object types the Agent may use (`granted-object-types`), which the
|
|
424
|
+
* workspace key reads too; their permissions beyond read are not given.
|
|
425
|
+
* When none answers, `unread` says why in words.
|
|
426
|
+
*/
|
|
427
|
+
export async function readAgentContext(source: SyncSource, row: AgentRow): Promise<AgentContext> {
|
|
428
|
+
if (Array.isArray(row.contextAndData)) return { grants: asGrants(row.contextAndData) }
|
|
429
|
+
let refused: string | null = null
|
|
430
|
+
if (source.organization) {
|
|
431
|
+
try {
|
|
432
|
+
return { grants: asGrants(rows(await source.organization.get(`/v1/blueprint/grants/agent/${encodeURIComponent(row.id)}`))) }
|
|
433
|
+
} catch (err) {
|
|
434
|
+
refused = unreadWhy(err)
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
const names = await source.workspace.find<unknown>(`/v1/blueprint/agents/${encodeURIComponent(row.id)}/granted-object-types`).catch(() => null)
|
|
438
|
+
const listed = (Array.isArray(names) ? names : rows<unknown>(names ?? [])).filter((n): n is string => typeof n === 'string')
|
|
439
|
+
// An empty answer may only mean the key can't see the Agent there, so it is not taken for "none".
|
|
440
|
+
if (listed.length) {
|
|
441
|
+
return {
|
|
442
|
+
grants: listed.map((n) => ({ objectType: `objectType/${n}`, permissions: ['read'] })).sort((a, b) => a.objectType.localeCompare(b.objectType)),
|
|
443
|
+
note: 'Its Context and data was read as the object types it may use: this platform doesn’t give their permissions to this key, so read is taken for each',
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
return { unread: refused ?? `this platform gives it only to an organization key. ${STEWARD_FIX}` }
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Which Functions and workflows each Agent may start. They are kept beside
|
|
451
|
+
* the Agent on the platform, not in its snapshot, and its procedures refuse
|
|
452
|
+
* to run without them; both are named by slug, so they travel as they are.
|
|
453
|
+
*/
|
|
454
|
+
async function readBindings(source: SyncSource, lists: Lists, warnings: string[]): Promise<Map<string, AgentBindings>> {
|
|
455
|
+
const out = new Map<string, AgentBindings>()
|
|
456
|
+
const at = (agent: string) => {
|
|
457
|
+
if (!out.has(agent)) out.set(agent, { functions: [], workflows: [] })
|
|
458
|
+
return out.get(agent)!
|
|
459
|
+
}
|
|
460
|
+
const clean = (row: BindingRow) => Object.fromEntries(Object.entries({ enabled: row.enabled, requiresApproval: row.requiresApproval, waitSeconds: row.waitSeconds }).filter(([, v]) => v !== undefined))
|
|
461
|
+
for (const fn of lists.functions) {
|
|
462
|
+
const rowsFor = await quietly(warnings, `Function ${fn.slug} bindings`, async () => rows<BindingRow>(await source.workspace.get(`/v1/functions/${encodeURIComponent(fn.slug)}/bindings`)), [])
|
|
463
|
+
for (const b of rowsFor) if (b.agentSlug) at(b.agentSlug).functions.push({ function: `function/${fn.slug}`, ...clean(b) })
|
|
464
|
+
}
|
|
465
|
+
for (const wf of lists.workflows) {
|
|
466
|
+
const rowsFor = await quietly(warnings, `Workflow ${wf.slug} bindings`, async () => rows<BindingRow>(await source.workspace.get(`/v1/workflows/${encodeURIComponent(wf.slug)}/bindings`)), [])
|
|
467
|
+
for (const b of rowsFor) if (b.agentSlug) at(b.agentSlug).workflows.push({ workflow: `workflow/${wf.slug}`, ...(b.access ? { access: b.access } : {}), ...clean(b) })
|
|
468
|
+
}
|
|
469
|
+
for (const b of out.values()) {
|
|
470
|
+
b.functions.sort((x, y) => x.function.localeCompare(y.function))
|
|
471
|
+
b.workflows.sort((x, y) => x.workflow.localeCompare(y.workflow))
|
|
472
|
+
}
|
|
473
|
+
return out
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
async function readAgents(source: SyncSource, lists: Lists, directory: Directory, opts: ReadOptions, warnings: string[], tools: () => Promise<PluginTools>): Promise<RemoteItem[]> {
|
|
477
|
+
const out: RemoteItem[] = []
|
|
478
|
+
const agents = lists.agents.filter((row) => wanted(opts, `agent/${row.agentId}`))
|
|
479
|
+
if (!agents.length) return out
|
|
480
|
+
const bindings = await readBindings(source, lists, warnings)
|
|
481
|
+
for (const row of agents) {
|
|
482
|
+
const ref = `agent/${row.agentId}`
|
|
483
|
+
const wantDraft = opts.drafts?.has(ref) || !row.currentVersionId
|
|
484
|
+
const it = item('agent', row.agentId, row.name, {}, { id: row.id })
|
|
485
|
+
let snapshot: Record<string, unknown> | null = null
|
|
486
|
+
try {
|
|
487
|
+
if (wantDraft) {
|
|
488
|
+
const draft = one<{ snapshot?: Record<string, unknown>; revision?: number } | null>(await source.workspace.get(`/v1/config/agents/${row.id}/draft-v2`))
|
|
489
|
+
snapshot = draft?.snapshot ?? null
|
|
490
|
+
if (!row.currentVersionId) it.notes.push('Not published on the platform: its draft was read')
|
|
491
|
+
else it.notes.push('Read from its draft, as asked')
|
|
492
|
+
if (draft?.revision !== undefined) it.version = `draft ${draft.revision}`
|
|
493
|
+
}
|
|
494
|
+
if (!snapshot) {
|
|
495
|
+
snapshot = one<Record<string, unknown>>(await source.workspace.get(`/v1/config/agents/${row.id}/snapshot/live`))
|
|
496
|
+
const detail = one<{ currentVersion?: { versionNumber?: number } | null }>(await source.workspace.get(`/v1/config/agents/${row.id}`))
|
|
497
|
+
if (detail?.currentVersion?.versionNumber) it.version = `v${detail.currentVersion.versionNumber}`
|
|
498
|
+
}
|
|
499
|
+
} catch (err) {
|
|
500
|
+
warnings.push(`Agent ${row.agentId}: ${(err as Error).message}`)
|
|
501
|
+
continue
|
|
502
|
+
}
|
|
503
|
+
const operating = ((snapshot.config as Record<string, unknown> | undefined)?.operatingConfig ?? (wantDraft ? null : row.operatingConfig)) as Record<string, unknown> | null
|
|
504
|
+
const toolSettings = Array.isArray(snapshot.appCapabilities) && snapshot.appCapabilities.length ? { apps: lists.apps, tools: await tools() } : undefined
|
|
505
|
+
const agentDoc = projectAgent(snapshot, it.notes, toolSettings)
|
|
506
|
+
// Its object tools carry the definitions they work on, named by slug below.
|
|
507
|
+
it.notes.push(...attachDefinitions(agentDoc, lists.objectDefinitions ?? null))
|
|
508
|
+
const docs: Array<{ path: string; doc: unknown; format: 'json' }> = [{ path: `agents/${row.agentId}/agent.json`, doc: agentDoc, format: 'json' }]
|
|
509
|
+
if (operating && typeof operating === 'object' && Object.keys(operating).length) docs.push({ path: `agents/${row.agentId}/operating.json`, doc: operating, format: 'json' })
|
|
510
|
+
const bound: AgentBindings = { ...(bindings.get(row.agentId) ?? { functions: [], workflows: [] }) }
|
|
511
|
+
// Context and data comes as the Agent's dependency: each object type it names is pulled with it.
|
|
512
|
+
const context = await readAgentContext(source, row)
|
|
513
|
+
if ('grants' in context) {
|
|
514
|
+
if (context.grants.length) bound.blueprint = context.grants
|
|
515
|
+
if (context.note) it.notes.push(context.note)
|
|
516
|
+
} else {
|
|
517
|
+
bound.blueprintUnread = true
|
|
518
|
+
it.notes.push(`Its Context and data couldn’t be read on the platform: ${context.unread}. Until it can be read, local dev gives it read access to the object types the project holds, as an adaptation, so locally it may read more than on the platform`)
|
|
519
|
+
}
|
|
520
|
+
if (bound.functions.length || bound.workflows.length || bound.blueprint?.length || bound.blueprintUnread) docs.push({ path: `agents/${row.agentId}/bindings.json`, doc: bound, format: 'json' })
|
|
521
|
+
await nameUnknownAgents(source, directory, docs)
|
|
522
|
+
out.push(finish(it, directory, docs))
|
|
523
|
+
}
|
|
524
|
+
return out
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
async function readWorkflows(source: SyncSource, lists: Lists, directory: Directory, opts: ReadOptions, warnings: string[]): Promise<RemoteItem[]> {
|
|
528
|
+
const out: RemoteItem[] = []
|
|
529
|
+
for (const row of lists.workflows) {
|
|
530
|
+
const ref = `workflow/${row.slug}`
|
|
531
|
+
if (!wanted(opts, ref)) continue
|
|
532
|
+
const n = opts.drafts?.has(ref) || !row.liveVersion ? row.latestDraftNumber ?? row.liveVersion : row.liveVersion
|
|
533
|
+
if (!n) continue
|
|
534
|
+
// Live, and its triggers on or off: `local dev` makes the runtime's copy the same (where that is safe there).
|
|
535
|
+
const it = item('workflow', row.slug, row.displayName || readableName(row.slug), {}, { version: `v${n}`, id: row.id, state: { live: n === row.liveVersion, enabled: row.enabled !== false } })
|
|
536
|
+
if (n !== row.liveVersion) it.notes.push(row.liveVersion ? `Read from draft version ${n}, as asked` : 'Not live on the platform: its draft was read')
|
|
537
|
+
else if (row.enabled === false) it.notes.push('Live on the platform with its triggers switched off; it is the same locally')
|
|
538
|
+
try {
|
|
539
|
+
const version = one<{ definition?: Record<string, unknown>; layout?: unknown }>(await source.workspace.get(`/v1/workflows/${encodeURIComponent(row.slug)}/versions/${n}`))
|
|
540
|
+
if (!version?.definition) continue
|
|
541
|
+
const doc = version.layout ? { ...version.definition, layout: version.layout } : version.definition
|
|
542
|
+
await nameUnknownAgents(source, directory, [{ doc }])
|
|
543
|
+
noteUnnamedActions(it, doc, directory)
|
|
544
|
+
out.push(finish(it, directory, [{ path: `workflows/${row.slug}.yaml`, doc, format: 'yaml' }]))
|
|
545
|
+
} catch (err) {
|
|
546
|
+
warnings.push(`Workflow ${row.slug}: ${(err as Error).message}`)
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
return out
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
const TEXT_LIMIT = 1_000_000
|
|
553
|
+
const SKIP_DIRS = new Set(['.frontera', 'node_modules', '.git', 'dist', '.next', 'coverage'])
|
|
554
|
+
|
|
555
|
+
/** Unpack a Function's source archive into memory: path → text, binary and tool state left out. */
|
|
556
|
+
function unpack(bytes: Uint8Array): Map<string, string> {
|
|
557
|
+
const dir = mkdtempSync(join(tmpdir(), 'frontera-sync-fn-'))
|
|
558
|
+
try {
|
|
559
|
+
const archive = join(dir, 'src.tgz')
|
|
560
|
+
writeFileSync(archive, bytes)
|
|
561
|
+
const out = join(dir, 'out')
|
|
562
|
+
spawnSync('mkdir', ['-p', out])
|
|
563
|
+
const res = spawnSync('tar', ['-xzf', archive, '-C', out], { stdio: 'ignore' })
|
|
564
|
+
if (res.status !== 0) throw new Error('the source archive could not be unpacked')
|
|
565
|
+
const files = new Map<string, string>()
|
|
566
|
+
const walk = (d: string) => {
|
|
567
|
+
for (const name of readdirSync(d)) {
|
|
568
|
+
const full = join(d, name)
|
|
569
|
+
const rel = relative(out, full).split('\\').join('/')
|
|
570
|
+
const st = statSync(full)
|
|
571
|
+
if (st.isDirectory()) {
|
|
572
|
+
if (!SKIP_DIRS.has(name)) walk(full)
|
|
573
|
+
} else if (st.isFile() && st.size <= TEXT_LIMIT) {
|
|
574
|
+
const buf = readFileSync(full)
|
|
575
|
+
if (!buf.includes(0)) files.set(rel, buf.toString('utf8'))
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
walk(out)
|
|
580
|
+
return files
|
|
581
|
+
} finally {
|
|
582
|
+
rmSync(dir, { recursive: true, force: true })
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/** The file and the relative files it imports, transitively, within the archive. */
|
|
587
|
+
function withImports(files: Map<string, string>, entry: string): string[] {
|
|
588
|
+
const seen = new Set<string>()
|
|
589
|
+
const visit = (path: string) => {
|
|
590
|
+
if (seen.has(path) || !files.has(path)) return
|
|
591
|
+
seen.add(path)
|
|
592
|
+
for (const m of files.get(path)!.matchAll(/from\s+['"](\.{1,2}\/[^'"]+)['"]/g)) {
|
|
593
|
+
const base = posix.normalize(posix.join(posix.dirname(path), m[1]!))
|
|
594
|
+
for (const candidate of [base, `${base}.ts`, `${base}/index.ts`]) if (files.has(candidate)) visit(candidate)
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
visit(entry)
|
|
598
|
+
return [...seen]
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/**
|
|
602
|
+
* A Function from the Functions editor: its saved draft as a project file
|
|
603
|
+
* (`editorFunctionModule`). `null` when nobody saved one, or its handler
|
|
604
|
+
* cannot be joined to its manifest in a file.
|
|
605
|
+
*/
|
|
606
|
+
async function readEditorDraft(source: SyncSource, row: FunctionRow, it: RemoteItem, warnings: string[]): Promise<boolean> {
|
|
607
|
+
try {
|
|
608
|
+
const draft = one<{ saved?: boolean; manifest?: Record<string, unknown>; handler?: string }>(await source.workspace.get(`/v1/functions/${encodeURIComponent(row.slug)}/draft`))
|
|
609
|
+
if (!draft?.saved || typeof draft.handler !== 'string') return false
|
|
610
|
+
const module = editorFunctionModule(row.slug, draft.manifest ?? {}, draft.handler)
|
|
611
|
+
if (!module) {
|
|
612
|
+
warnings.push(`Function ${row.slug}: its editor draft's handler has no plain \`export default\`, so it was not pulled`)
|
|
613
|
+
return false
|
|
614
|
+
}
|
|
615
|
+
it.files[`functions/${row.slug}.ts`] = module
|
|
616
|
+
return true
|
|
617
|
+
} catch (err) {
|
|
618
|
+
warnings.push(`Function ${row.slug}: ${(err as Error).message}`)
|
|
619
|
+
return false
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
async function readFunctions(source: SyncSource, lists: Lists, opts: ReadOptions, warnings: string[]): Promise<RemoteItem[]> {
|
|
624
|
+
const out: RemoteItem[] = []
|
|
625
|
+
for (const row of lists.functions) {
|
|
626
|
+
const ref = `function/${row.slug}`
|
|
627
|
+
if (!wanted(opts, ref)) continue
|
|
628
|
+
// Switched off in the workspace: off here too (`local dev`), as a workflow is.
|
|
629
|
+
const state = { live: !!row.liveVersion, enabled: row.enabled !== false }
|
|
630
|
+
if (!row.liveVersion || opts.drafts?.has(ref)) {
|
|
631
|
+
// Never deployed, or its draft asked for: what the Functions editor holds.
|
|
632
|
+
const it = item('function', row.slug, readableName(row.slug), {}, { version: row.liveVersion ? `v${row.liveVersion}` : 'draft', id: row.id, state: { ...state, live: false } })
|
|
633
|
+
if (!(await readEditorDraft(source, row, it, warnings))) continue
|
|
634
|
+
it.notes.push(row.liveVersion ? 'Read from its draft in the Functions editor, as asked' : 'Never deployed in the workspace: its draft in the Functions editor was read')
|
|
635
|
+
if (!state.enabled) it.notes.push('Switched off in the workspace; it is off here too')
|
|
636
|
+
it.requires = []
|
|
637
|
+
out.push(it)
|
|
638
|
+
continue
|
|
639
|
+
}
|
|
640
|
+
const it = item('function', row.slug, readableName(row.slug), {}, { version: `v${row.liveVersion}`, id: row.id, state })
|
|
641
|
+
if (!state.enabled) it.notes.push('Switched off in the workspace; it is off here too')
|
|
642
|
+
let files: Map<string, string>
|
|
643
|
+
try {
|
|
644
|
+
files = unpack(await source.workspace.getBytes(`/v1/functions/${encodeURIComponent(row.slug)}/versions/${row.liveVersion}/source`))
|
|
645
|
+
} catch (err) {
|
|
646
|
+
// A version deployed from the Functions editor keeps no source archive: its source is the editor's draft.
|
|
647
|
+
if (await readEditorDraft(source, row, it, [])) {
|
|
648
|
+
it.notes.push('Deployed from the Functions editor; its draft there was read')
|
|
649
|
+
it.requires = []
|
|
650
|
+
out.push(it)
|
|
651
|
+
continue
|
|
652
|
+
}
|
|
653
|
+
warnings.push(`Function ${row.slug}: ${(err as Error).message}`)
|
|
654
|
+
continue
|
|
655
|
+
}
|
|
656
|
+
const single = `functions/${row.slug}.ts`
|
|
657
|
+
if (files.has(single)) {
|
|
658
|
+
// A Function written in a project: its file, the helpers it imports and its test, and the
|
|
659
|
+
// shared files it imports from outside functions/, where its source keeps them.
|
|
660
|
+
const imported = withImports(files, single)
|
|
661
|
+
const keep = imported.filter((p) => p.startsWith('functions/'))
|
|
662
|
+
const test = `functions/${row.slug}.test.ts`
|
|
663
|
+
if (files.has(test)) keep.push(test)
|
|
664
|
+
const beyond = imported.filter((p) => !p.startsWith('functions/') && !p.startsWith('vendor/')).sort()
|
|
665
|
+
const shared = beyond.filter(isSharedFunctionFile)
|
|
666
|
+
const outside = beyond.filter((p) => !isSharedFunctionFile(p))
|
|
667
|
+
for (const p of [...keep, ...shared]) it.files[p] = files.get(p)!
|
|
668
|
+
const list = (paths: string[]) => (paths.length > 3 ? `${paths.slice(0, 3).join(', ')} and ${paths.length - 3} more` : paths.length > 1 ? `${paths.slice(0, -1).join(', ')} and ${paths[paths.length - 1]}` : paths.join(''))
|
|
669
|
+
if (shared.length) it.notes.push(`Brings ${list(shared)}, which it imports from outside functions/, where its source keeps ${shared.length === 1 ? 'it' : 'them'}, so it builds as in the workspace`)
|
|
670
|
+
if (outside.length) {
|
|
671
|
+
it.notes.push(`Imports ${list(outside)}, in a folder that holds the project’s own items: ${outside.length === 1 ? 'it is' : 'they are'} not pulled, so local dev can’t build it until the project has ${outside.length === 1 ? 'that file' : 'those files'}`)
|
|
672
|
+
warnings.push(`Function ${row.slug}: imports ${list(outside)}, which a pull does not write into a folder of the project’s own items`)
|
|
673
|
+
}
|
|
674
|
+
} else {
|
|
675
|
+
// A Function deployed from its own folder: the folder, under functions/<slug>/.
|
|
676
|
+
for (const [p, content] of files) if (!p.startsWith('.')) it.files[`functions/${row.slug}/${p}`] = content
|
|
677
|
+
if (!Object.keys(it.files).length) continue
|
|
678
|
+
it.notes.push('Deployed from its own folder; pulled as functions/' + row.slug + '/')
|
|
679
|
+
}
|
|
680
|
+
// Functions name other resources only in their code, if at all; nothing to rewrite.
|
|
681
|
+
it.requires = []
|
|
682
|
+
out.push(it)
|
|
683
|
+
}
|
|
684
|
+
noteSharedFunctionFiles(out, warnings)
|
|
685
|
+
return out
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
/**
|
|
689
|
+
* Two Functions whose sources hold the same file (a helper both import, or
|
|
690
|
+
* one Function's own file another imports) share one copy in the project.
|
|
691
|
+
* The same contents work for both; different contents mean one of them
|
|
692
|
+
* would run with the other's, so each says so, and the fetch warns.
|
|
693
|
+
*/
|
|
694
|
+
export function noteSharedFunctionFiles(items: RemoteItem[], warnings: string[]): void {
|
|
695
|
+
const holders = new Map<string, RemoteItem[]>()
|
|
696
|
+
for (const it of items) for (const path of Object.keys(it.files)) holders.set(path, [...(holders.get(path) ?? []), it])
|
|
697
|
+
for (const [path, list] of holders) {
|
|
698
|
+
if (list.length < 2 || new Set(list.map((i) => i.files[path])).size < 2) continue
|
|
699
|
+
const names = list.map((i) => i.name).sort()
|
|
700
|
+
const and = (words: string[]) => (words.length < 2 ? words.join('') : `${words.slice(0, -1).join(', ')} and ${words[words.length - 1]}`)
|
|
701
|
+
for (const it of list) {
|
|
702
|
+
const others = names.filter((n) => n !== it.name)
|
|
703
|
+
it.notes.push(`${path} is in ${others.length === 1 ? 'Function' : 'Functions'} ${and(others)} too, with different contents: the project holds one copy, so they can’t all run as the workspace has them. Give each its own file`)
|
|
704
|
+
}
|
|
705
|
+
warnings.push(`Functions ${and(names)} each hold ${path} with different contents; the project can keep only one`)
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
/** An asset larger than this stays on the platform, named in the skill's notes. */
|
|
710
|
+
const ASSET_LIMIT = 20 * 1024 * 1024
|
|
711
|
+
|
|
712
|
+
/**
|
|
713
|
+
* A skill's assets (images, fonts, templates), each at the path the skill
|
|
714
|
+
* gives it: from the sync route, which serves them to the project's workspace
|
|
715
|
+
* key by the storage key the skill names; on a platform without it, from the
|
|
716
|
+
* skill-asset download route `frontera skill pull` reads (sessions only).
|
|
717
|
+
* They travel beside the skill's text files, not among them
|
|
718
|
+
* (`RemoteItem.assets`): a pull writes the ones the project does not have, and
|
|
719
|
+
* `skill push` uploads them again on the local runtime.
|
|
720
|
+
*/
|
|
721
|
+
async function readSkillAsset(source: SyncSource, skillId: string, storageKey: string, filename: string): Promise<Uint8Array> {
|
|
722
|
+
try {
|
|
723
|
+
return await source.workspace.getBytes(`/v1/sync/skills/${encodeURIComponent(skillId)}/asset?key=${encodeURIComponent(storageKey)}`)
|
|
724
|
+
} catch (err) {
|
|
725
|
+
if (!(err instanceof CliError) || err.code !== 'NOT_FOUND') throw err
|
|
726
|
+
// An older platform has no such route; the download route then answers, or says why not.
|
|
727
|
+
return await source.workspace.getBytes(`/v1/skill-assets/${encodeURIComponent(source.workspaceId)}/${encodeURIComponent(filename)}`).catch((older: unknown) => {
|
|
728
|
+
throw older instanceof CliError && older.code === 'UNAUTHORIZED'
|
|
729
|
+
? new CliError('this platform gives skill assets to a signed-in person only, so it stays there; a newer platform gives them to the workspace key', { code: 'FORBIDDEN', hint: 'download it from the skill on the platform, or fetch again once the platform is newer', cause: older })
|
|
730
|
+
: older
|
|
731
|
+
})
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
async function readSkillAssets(source: SyncSource, name: string, skillId: string, assets: NonNullable<SkillDoc['assets']>, it: RemoteItem, warnings: string[]): Promise<void> {
|
|
736
|
+
const left: string[] = []
|
|
737
|
+
it.assets = []
|
|
738
|
+
for (const asset of assets) {
|
|
739
|
+
const filename = assetFilename(asset.storageKey)
|
|
740
|
+
if (!filename || !asset.path || (asset.bytes ?? 0) > ASSET_LIMIT) {
|
|
741
|
+
left.push(asset.path || asset.storageKey)
|
|
742
|
+
continue
|
|
743
|
+
}
|
|
744
|
+
try {
|
|
745
|
+
const bytes = await readSkillAsset(source, skillId, asset.storageKey, filename)
|
|
746
|
+
it.assets.push({ path: `skills/${name}/${asset.path}`, bytes })
|
|
747
|
+
} catch (err) {
|
|
748
|
+
warnings.push(`Skill ${name}: asset ${asset.path}: ${(err as Error).message}`)
|
|
749
|
+
left.push(asset.path)
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
if (it.assets.length) it.notes.push(`${it.assets.length} asset${it.assets.length === 1 ? '' : 's'} (images, fonts, templates) come too; one the project already has is kept`)
|
|
753
|
+
if (left.length) it.notes.push(`Not brought, so the skill works without ${left.length === 1 ? 'it' : 'them'} here: ${left.join(', ')}`)
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
async function readSkills(source: SyncSource, lists: Lists, opts: ReadOptions, warnings: string[]): Promise<RemoteItem[]> {
|
|
757
|
+
const out: RemoteItem[] = []
|
|
758
|
+
for (const row of lists.skills) {
|
|
759
|
+
if (!wanted(opts, `skill/${row.name}`)) continue
|
|
760
|
+
try {
|
|
761
|
+
const doc = one<SkillDoc>(await source.workspace.get(`/v1/config/workspace-skills/${row.id}`))
|
|
762
|
+
const it = item('skill', row.name, doc.displayName || row.displayName || readableName(row.name), { [`skills/${row.name}/SKILL.md`]: toSkillMd(doc) }, { id: row.id })
|
|
763
|
+
for (const ref of doc.references ?? []) it.files[`skills/${row.name}/refs/${ref.filename}`] = ref.content
|
|
764
|
+
for (const script of doc.scripts ?? []) it.files[`skills/${row.name}/${script.path}`] = script.content
|
|
765
|
+
if (doc.assets?.length) await readSkillAssets(source, row.name, row.id, doc.assets, it, warnings)
|
|
766
|
+
out.push(it)
|
|
767
|
+
} catch (err) {
|
|
768
|
+
warnings.push(`Skill ${row.name}: ${(err as Error).message}`)
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
return out
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
async function readKnowledge(source: SyncSource, lists: Lists, opts: ReadOptions, warnings: string[]): Promise<RemoteItem[]> {
|
|
775
|
+
const out: RemoteItem[] = []
|
|
776
|
+
for (const k of lists.knowledge.filter((x) => wanted(opts, `knowledge/${refName(x.name)}`))) {
|
|
777
|
+
const name = refName(k.name)
|
|
778
|
+
const doc = Object.fromEntries(Object.entries({
|
|
779
|
+
name: k.name,
|
|
780
|
+
description: k.description ?? undefined,
|
|
781
|
+
retrieval: k.retrieval ?? undefined,
|
|
782
|
+
chunker: k.chunker ?? undefined,
|
|
783
|
+
chunkSize: k.chunkSize ?? undefined,
|
|
784
|
+
chunkOverlap: k.chunkOverlap ?? undefined,
|
|
785
|
+
similarityThreshold: k.similarityThreshold ?? undefined,
|
|
786
|
+
topK: k.topK ?? undefined,
|
|
787
|
+
// How it searches and reads documents: hybrid search and reranking, extraction, OCR.
|
|
788
|
+
hybridConfig: k.hybridConfig ?? undefined,
|
|
789
|
+
extractorConfig: k.extractorConfig ?? undefined,
|
|
790
|
+
ocrConfig: k.ocrConfig ?? undefined,
|
|
791
|
+
}).filter(([, v]) => v !== undefined))
|
|
792
|
+
const it = item('knowledge', name, k.name, { [`knowledge/${name}/knowledge.json`]: json(doc) }, {
|
|
793
|
+
notes: ['Its documents stay on the platform: add them as Markdown or text files in this folder and `local dev` uploads them'],
|
|
794
|
+
requires: [],
|
|
795
|
+
id: k.id,
|
|
796
|
+
})
|
|
797
|
+
// Its quality checks come with it, their documents named by path; their runs stay on the platform.
|
|
798
|
+
const checks = await quietly(warnings, `Knowledge ${k.name} quality checks`, async () => {
|
|
799
|
+
const fixtures = fixtureRows(await source.workspace.get(`/v1/workspace-knowledge/${encodeURIComponent(k.id)}/eval/fixtures`))
|
|
800
|
+
if (!fixtures.length) return null
|
|
801
|
+
const named = fixtures.some((f) => f.expectedSourceIds?.length)
|
|
802
|
+
// Loaded here: the documents module reads this one's helpers.
|
|
803
|
+
const { readFolders } = await import('./local-knowledge-documents')
|
|
804
|
+
const paths = named
|
|
805
|
+
? sourcePaths(sourceRows(await source.workspace.get(`/v1/workspace-knowledge/${encodeURIComponent(k.id)}/sources`)), await readFolders(source.workspace, k.id))
|
|
806
|
+
: new Map<string, string>()
|
|
807
|
+
return checksFromFixtures(fixtures, paths)
|
|
808
|
+
}, null)
|
|
809
|
+
if (checks) {
|
|
810
|
+
it.files[qualityChecksPath(name)] = qualityChecksText(checks.checks)
|
|
811
|
+
if (checks.unnamed) it.notes.push(`${checks.unnamed} of its quality checks’ expected documents ${checks.unnamed === 1 ? 'is' : 'are'} no longer in the knowledge base, so ${checks.unnamed === 1 ? 'it is' : 'they are'} left out of the file`)
|
|
812
|
+
}
|
|
813
|
+
out.push(it)
|
|
814
|
+
}
|
|
815
|
+
return out
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
/**
|
|
819
|
+
* What a Plugin may do on the workspace, as `policy` in its file: whether it
|
|
820
|
+
* is on, its Action policy (`read_only` refuses every tool that changes
|
|
821
|
+
* something), what a tool found later starts as, and each tool's own setting
|
|
822
|
+
* (on or off, an approval it needs, limits on its inputs). The local runtime
|
|
823
|
+
* applies the same, so an Agent cannot call a tool there that the workspace
|
|
824
|
+
* keeps from it.
|
|
825
|
+
*/
|
|
826
|
+
export function pluginPolicy(install: NonNullable<AppRow['install']>, tools: ToolRow[] | undefined, notes: string[]): Record<string, unknown> {
|
|
827
|
+
const own: Record<string, Record<string, unknown>> = {}
|
|
828
|
+
let readable = !!tools?.length
|
|
829
|
+
for (const t of tools ?? []) {
|
|
830
|
+
if (!('policy' in t)) readable = false
|
|
831
|
+
if (!t.policy) continue
|
|
832
|
+
const setting = Object.fromEntries(Object.entries({ enabled: t.policy.enabled, requiresApproval: t.policy.requiresApprovalOverride, paramConstraints: t.policy.paramConstraints }).filter(([, v]) => v !== null && v !== undefined))
|
|
833
|
+
if (Object.keys(setting).length) own[t.name] = setting
|
|
834
|
+
}
|
|
835
|
+
const actions = install.actionPolicy ?? 'read_only'
|
|
836
|
+
if (tools?.length && !readable && actions === 'custom') notes.push('Each tool’s own setting could not be read from this platform; locally its tools stay off until set in Studio → Plugins')
|
|
837
|
+
return {
|
|
838
|
+
enabled: install.enabled ?? true,
|
|
839
|
+
actions,
|
|
840
|
+
newTools: install.newCapabilityDefault ?? 'disabled',
|
|
841
|
+
...(Object.keys(own).length ? { tools: Object.fromEntries(Object.entries(own).sort(([a], [b]) => a.localeCompare(b))) } : {}),
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
/** The image types a Plugin's icon may be uploaded as, by extension. */
|
|
846
|
+
const ICON_TYPES: Record<string, string> = { 'image/png': 'png', 'image/jpeg': 'jpg', 'image/webp': 'webp', 'image/gif': 'gif', 'image/svg+xml': 'svg' }
|
|
847
|
+
|
|
848
|
+
/**
|
|
849
|
+
* A Plugin's icon, read from the short-lived link the Plugin list gives
|
|
850
|
+
* (`iconUrl`), as `mcp/<name>.icon.<ext>` beside its file. Null when it has
|
|
851
|
+
* none or it could not be read (said in its notes).
|
|
852
|
+
*/
|
|
853
|
+
async function readPluginIcon(name: string, url: string | null | undefined, notes: string[]): Promise<{ path: string; bytes: Uint8Array } | null> {
|
|
854
|
+
if (!url) return null
|
|
855
|
+
try {
|
|
856
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(30_000) })
|
|
857
|
+
if (!res.ok) throw new Error(`answered ${res.status}`)
|
|
858
|
+
const type = (res.headers.get('content-type') ?? '').split(';')[0]!.trim().toLowerCase()
|
|
859
|
+
const ext = ICON_TYPES[type] ?? /\.(png|jpe?g|webp|gif|svg)(?:\?|$)/i.exec(new URL(url).pathname)?.[1]?.toLowerCase().replace('jpeg', 'jpg')
|
|
860
|
+
if (!ext) throw new Error(`not an image (${type || 'no type'})`)
|
|
861
|
+
const bytes = new Uint8Array(await res.arrayBuffer())
|
|
862
|
+
if (bytes.byteLength > 2 * 1024 * 1024) throw new Error('over 2 MB')
|
|
863
|
+
return { path: `mcp/${name}.icon.${ext}`, bytes }
|
|
864
|
+
} catch (err) {
|
|
865
|
+
notes.push(`Its icon stays on the platform: ${(err as Error).message}`)
|
|
866
|
+
return null
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
async function readPlugins(lists: Lists, opts: ReadOptions, tools: PluginTools): Promise<RemoteItem[]> {
|
|
871
|
+
const out: RemoteItem[] = []
|
|
872
|
+
for (const a of lists.apps) {
|
|
873
|
+
if (!a.install || (a.catalog?.kind ?? '').toLowerCase() !== 'mcp' || !wanted(opts, `plugin/${a.install.installName}`)) continue
|
|
874
|
+
const config = (a.install.config ?? {}) as Record<string, unknown>
|
|
875
|
+
const notes: string[] = []
|
|
876
|
+
// Its own words and icon in the Plugin list, before its connection settings.
|
|
877
|
+
const doc: Record<string, unknown> = a.install.description?.trim() ? { description: a.install.description.trim() } : {}
|
|
878
|
+
const icon = await readPluginIcon(a.install.installName, a.install.iconUrl, notes)
|
|
879
|
+
if (icon) doc.icon = icon.path
|
|
880
|
+
for (const [k, v] of Object.entries(config)) {
|
|
881
|
+
if (k === 'headers' && v && typeof v === 'object' && !Array.isArray(v)) {
|
|
882
|
+
// A header that carries no credential keeps its value; the others come down declared and
|
|
883
|
+
// empty, as placeholders: their values are never pulled (they come back masked anyway).
|
|
884
|
+
doc.headers = Object.fromEntries(Object.entries(v as Record<string, unknown>).map(([h, value]) =>
|
|
885
|
+
[h, NON_SECRET_HEADERS.has(h.toLowerCase()) && typeof value === 'string' && !MASKED.test(value) ? value : '']))
|
|
886
|
+
continue
|
|
887
|
+
}
|
|
888
|
+
doc[k] = SECRETISH.test(k) || (typeof v === 'string' && MASKED.test(v)) ? '' : v
|
|
889
|
+
}
|
|
890
|
+
const inputs = pluginSecretInputs(a.install.installName, doc)
|
|
891
|
+
if (inputs.length) notes.push(`Secrets come down empty, never with their values: ${inputs.map((i) => `${i.header ? 'header ' : ''}${i.field} (\`frontera secret set ${i.secret}\`)`).join(', ')}; \`local dev\` fills each from that secret on this computer`)
|
|
892
|
+
if (typeof doc.url === 'string' && !/^https?:\/\/(127\.0\.0\.1|localhost)/.test(doc.url)) notes.push(`Points at ${doc.url}; the local runtime must be able to reach it`)
|
|
893
|
+
doc.policy = pluginPolicy(a.install, tools.get(a.install.id), notes)
|
|
894
|
+
const it = item('plugin', a.install.installName, a.catalog?.displayName || readableName(a.install.installName), { [`mcp/${a.install.installName}.plugin.json`]: json(doc) }, { notes, requires: [], id: a.install.id })
|
|
895
|
+
if (icon) it.assets = [icon]
|
|
896
|
+
out.push(it)
|
|
897
|
+
}
|
|
898
|
+
return out
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
interface BundleAnswer { definition: Parameters<typeof toFiles>[0] | null; datasetNames: Record<string, string> }
|
|
902
|
+
|
|
903
|
+
/**
|
|
904
|
+
* The saved object sets as Blueprint files (`blueprint/object-sets/<name>.yaml`):
|
|
905
|
+
* the object type each returns and its definition, or, for a static set, the
|
|
906
|
+
* keys of the records it lists. A set naming another (`reference`) names it
|
|
907
|
+
* `objectSet/<name>`. `local dev` saves them on the local runtime after the
|
|
908
|
+
* Blueprint is published there.
|
|
909
|
+
*/
|
|
910
|
+
export function objectSetItems(sets: ObjectSetRow[], directory: Directory, objectTypes?: Set<string>): RemoteItem[] {
|
|
911
|
+
const names = new Map([...(directory.objectSet ?? new Map<string, string>())].map(([name, id]) => [id, name]))
|
|
912
|
+
return sets.flatMap((set) => {
|
|
913
|
+
const name = names.get(set.id)
|
|
914
|
+
// A set on an object type this read did not bring cannot apply: the type is not in the project.
|
|
915
|
+
if (!name || (objectTypes && !objectTypes.has(set.objectType))) return []
|
|
916
|
+
const doc: Record<string, unknown> = {
|
|
917
|
+
name: set.name,
|
|
918
|
+
...(set.description ? { description: set.description } : {}),
|
|
919
|
+
objectType: set.objectType,
|
|
920
|
+
kind: set.kind,
|
|
921
|
+
...(set.kind === 'static' ? { records: set.staticPks ?? [] } : { definition: set.definition ?? null }),
|
|
922
|
+
}
|
|
923
|
+
const it = item('blueprint', `object-sets/${name}`, set.name, {}, { id: set.id })
|
|
924
|
+
const { doc: named, unresolved } = toNames(doc, directory)
|
|
925
|
+
it.unresolved.push(...unresolved.map((u) => ({ ...u, path: `blueprint/object-sets/${name}.yaml: ${u.path}` })))
|
|
926
|
+
it.files[`blueprint/object-sets/${name}.yaml`] = stringifyYaml(named, { lineWidth: 0 })
|
|
927
|
+
const others = referencesIn(named).filter((r) => r.kind === 'objectSet' && r.name !== name).map((r) => itemRef(r.kind, r.name))
|
|
928
|
+
it.requires = [itemRef('objectType', set.objectType), ...new Set(others)].sort()
|
|
929
|
+
if (set.kind === 'static') {
|
|
930
|
+
const n = set.staticPks?.length ?? 0
|
|
931
|
+
it.notes.push(`Lists ${n} ${set.objectType} record${n === 1 ? '' : 's'} by key; locally it holds those of them this runtime’s data has`)
|
|
932
|
+
}
|
|
933
|
+
return [it]
|
|
934
|
+
})
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
type Lifecycle = { draft?: { definition?: BundleAnswer['definition'] } | null; activeRelease?: { definition?: BundleAnswer['definition'] } | null }
|
|
938
|
+
|
|
939
|
+
/**
|
|
940
|
+
* The organization's unpublished Blueprint draft cut to what the workspace's
|
|
941
|
+
* slice holds: its object types (by API name, as the workspace is granted
|
|
942
|
+
* them now), the relations between them and their metrics, with the draft's
|
|
943
|
+
* Actions on them. Read with the organization key: the draft is a
|
|
944
|
+
* steward's, so a workspace key does not read it.
|
|
945
|
+
*/
|
|
946
|
+
export function sliceDraft(draft: Bundle, granted: ReadonlySet<string>): Bundle {
|
|
947
|
+
const def = draft as unknown as { objects: Array<{ id: string; apiName: string }>; relationships: Array<{ fromObjectId: string; toObjectId: string }>; metrics: Array<{ objectId: string }>; actions?: Array<Record<string, any>> }
|
|
948
|
+
const objects = def.objects.filter((o) => granted.has(o.apiName))
|
|
949
|
+
const ids = new Set(objects.map((o) => o.id))
|
|
950
|
+
const actions = (def.actions ?? []).filter((a) => {
|
|
951
|
+
const changes = (Array.isArray(a.businessOutcomes) ? a.businessOutcomes : []).flatMap((o: Record<string, any>) => (Array.isArray(o?.expectedChanges) ? o.expectedChanges : [])) as Array<Record<string, any>>
|
|
952
|
+
return [a.subject?.objectTypeId, ...changes.map((c) => c?.objectTypeId)].filter((v): v is string => typeof v === 'string').every((id) => ids.has(id))
|
|
953
|
+
})
|
|
954
|
+
return {
|
|
955
|
+
...def,
|
|
956
|
+
objects,
|
|
957
|
+
relationships: def.relationships.filter((r) => ids.has(r.fromObjectId) && ids.has(r.toObjectId)),
|
|
958
|
+
metrics: def.metrics.filter((m) => ids.has(m.objectId)),
|
|
959
|
+
...('actions' in def ? { actions } : {}),
|
|
960
|
+
} as unknown as Bundle
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
async function readBlueprint(
|
|
964
|
+
source: SyncSource,
|
|
965
|
+
mode: 'workspace' | 'organization',
|
|
966
|
+
warnings: string[],
|
|
967
|
+
notes: string[],
|
|
968
|
+
sets: { lists: Lists; directory: Directory } = { lists: { agents: [], workflows: [], functions: [], knowledge: [], apps: [], skills: [] }, directory: {} },
|
|
969
|
+
opts: { draft?: boolean } = {},
|
|
970
|
+
): Promise<RemoteItem[]> {
|
|
971
|
+
let answer: BundleAnswer | null = null
|
|
972
|
+
// The draft read in place of the release, already holding its Actions.
|
|
973
|
+
let fromDraft = false
|
|
974
|
+
if (mode === 'organization' && source.organization) {
|
|
975
|
+
const lifecycle = one<Lifecycle>(await source.organization.get('/v1/blueprint/lifecycle'))
|
|
976
|
+
fromDraft = !!lifecycle.draft?.definition && (!!opts.draft || !lifecycle.activeRelease?.definition)
|
|
977
|
+
answer = { definition: (fromDraft ? lifecycle.draft?.definition : lifecycle.activeRelease?.definition) ?? null, datasetNames: await datasetNames(source.organization) }
|
|
978
|
+
if (fromDraft) notes.push(opts.draft ? 'The Blueprint was read from its unpublished draft, as asked' : 'The Blueprint has no published release; its draft was read')
|
|
979
|
+
} else {
|
|
980
|
+
const sliced = await source.workspace.find<unknown>('/v1/sync/blueprint')
|
|
981
|
+
if (sliced) answer = one<BundleAnswer>(sliced)
|
|
982
|
+
else if (source.organization) {
|
|
983
|
+
notes.push('This platform cannot cut the Blueprint to the workspace yet; the organization key read the whole model')
|
|
984
|
+
return readBlueprint(source, 'organization', warnings, notes, sets, opts)
|
|
985
|
+
} else {
|
|
986
|
+
warnings.push('Blueprint: this platform cannot export a workspace’s Blueprint yet; connect an organization key to pull it')
|
|
987
|
+
return []
|
|
988
|
+
}
|
|
989
|
+
if (opts.draft && !source.organization) notes.push('Reading the Blueprint’s unpublished draft takes an organization key; its published release was read')
|
|
990
|
+
if (opts.draft && source.organization && answer) {
|
|
991
|
+
// The draft, cut to the object types the workspace's slice holds now.
|
|
992
|
+
const lifecycle = one<Lifecycle>(await source.organization.get('/v1/blueprint/lifecycle'))
|
|
993
|
+
const granted = new Set(((answer.definition as { objects?: Array<{ apiName: string }> } | null)?.objects ?? []).map((o) => o.apiName))
|
|
994
|
+
if (lifecycle.draft?.definition) {
|
|
995
|
+
answer = { definition: sliceDraft(lifecycle.draft.definition, granted), datasetNames: { ...(answer.datasetNames ?? {}), ...(await datasetNames(source.organization)) } }
|
|
996
|
+
fromDraft = true
|
|
997
|
+
notes.push('The Blueprint was read from its unpublished draft, as asked, cut to the object types this workspace is granted')
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
if (!answer?.definition) return []
|
|
1002
|
+
// The workspace's slice carries no governed Actions: reading them is organization-grain.
|
|
1003
|
+
// With the organization key, the published Actions on the slice's object types come along.
|
|
1004
|
+
const definition = mode === 'workspace' && source.organization && !fromDraft ? await withActions(source.organization, answer.definition, warnings, notes) : answer.definition
|
|
1005
|
+
const model = toFiles(definition, new Map(Object.entries(answer.datasetNames ?? {})))
|
|
1006
|
+
const objectTypes = new Set(model.files.flatMap((f) => (f.path.startsWith('object-types/') ? [f.apiName] : [])))
|
|
1007
|
+
const saved = objectSetItems(sets.lists.objectSets ?? [], sets.directory, objectTypes)
|
|
1008
|
+
return [...model.files.map((file) => {
|
|
1009
|
+
const name = file.path.replace(/\.(ya?ml|json)$/, '')
|
|
1010
|
+
// Named by its display name; its kind ("Action", "Object type") goes before it where it is said.
|
|
1011
|
+
const shown = (file.document as { displayName?: unknown }).displayName
|
|
1012
|
+
const it = { ...item('blueprint', name, typeof shown === 'string' && shown.trim() ? shown.trim() : file.apiName, { [`blueprint/${file.path}`]: serialize(file.path, file.document as Record<string, unknown>) }), requires: [] as string[] }
|
|
1013
|
+
if (file.path.startsWith('actions/')) {
|
|
1014
|
+
// An Action brings the object types it acts on, and says what it does on the local runtime.
|
|
1015
|
+
const doc = file.document as Record<string, any>
|
|
1016
|
+
const fit = actionFit(doc)
|
|
1017
|
+
if (fit.subject) it.requires = [itemRef('objectType', fit.subject)]
|
|
1018
|
+
it.notes.push(fit.runsLocally ? `Deployed on the local runtime when it applies: it writes ${fit.subject ?? 'its object'}’s records there` : `Published switched off on the local runtime: ${fit.reason}`)
|
|
1019
|
+
}
|
|
1020
|
+
return it
|
|
1021
|
+
}), ...saved]
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
type Bundle = NonNullable<BundleAnswer['definition']>
|
|
1025
|
+
|
|
1026
|
+
/**
|
|
1027
|
+
* The organization's published Actions whose subject, and every object type
|
|
1028
|
+
* they change, is in the workspace's slice. Read with the organization key;
|
|
1029
|
+
* a refusal leaves them out and says so.
|
|
1030
|
+
*/
|
|
1031
|
+
async function withActions(org: SyncClient, sliced: Bundle, warnings: string[], notes: string[]): Promise<Bundle> {
|
|
1032
|
+
const def = sliced as Bundle & { schemaVersion?: number; objects: Array<{ id: string }>; actions?: unknown[] }
|
|
1033
|
+
if ((def.schemaVersion ?? 1) < 2) return sliced
|
|
1034
|
+
let published: Array<Record<string, any>> = []
|
|
1035
|
+
try {
|
|
1036
|
+
const lifecycle = one<{ activeRelease?: { definition?: { actions?: Array<Record<string, any>> } } | null }>(await org.get('/v1/blueprint/lifecycle'))
|
|
1037
|
+
published = lifecycle.activeRelease?.definition?.actions ?? []
|
|
1038
|
+
} catch (err) {
|
|
1039
|
+
warnings.push(`Governed Actions: ${(err as Error).message}`)
|
|
1040
|
+
return sliced
|
|
1041
|
+
}
|
|
1042
|
+
const ids = new Set(def.objects.map((o) => o.id))
|
|
1043
|
+
const kept: Array<Record<string, any>> = []
|
|
1044
|
+
const outside: string[] = []
|
|
1045
|
+
for (const action of published) {
|
|
1046
|
+
const changes = (Array.isArray(action.businessOutcomes) ? action.businessOutcomes : []).flatMap((o: Record<string, any>) => (Array.isArray(o?.expectedChanges) ? o.expectedChanges : [])) as Array<Record<string, any>>
|
|
1047
|
+
const touched = [action.subject?.objectTypeId, ...changes.map((c) => c?.objectTypeId)].filter((v): v is string => typeof v === 'string')
|
|
1048
|
+
if (touched.every((id) => ids.has(id))) kept.push(action)
|
|
1049
|
+
else outside.push(String(action.apiName))
|
|
1050
|
+
}
|
|
1051
|
+
if (outside.length) notes.push(`${outside.length} governed Action${outside.length === 1 ? ' acts' : 's act'} on object types this workspace isn’t granted, so ${outside.length === 1 ? 'it stays' : 'they stay'} out: ${outside.join(', ')}`)
|
|
1052
|
+
return { ...def, actions: kept } as Bundle
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
async function datasetNames(org: SyncClient): Promise<Record<string, string>> {
|
|
1056
|
+
const out: Record<string, string> = {}
|
|
1057
|
+
try {
|
|
1058
|
+
// The list pages at 100; follow its cursor.
|
|
1059
|
+
let cursor: string | null = null
|
|
1060
|
+
for (let page = 0; page < 50; page++) {
|
|
1061
|
+
const listed: { items?: Array<{ id: string; apiName: string }>; nextCursor?: string | null } = one(await org.get(`/v1/data-integration/datasets?limit=100${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`))
|
|
1062
|
+
for (const d of listed.items ?? []) {
|
|
1063
|
+
const revisions = rows<{ id: string }>(await org.get(`/v1/data-integration/datasets/${d.id}/revisions`))
|
|
1064
|
+
for (const r of revisions) out[r.id] = d.apiName
|
|
1065
|
+
}
|
|
1066
|
+
cursor = listed.nextCursor ?? null
|
|
1067
|
+
if (!cursor) break
|
|
1068
|
+
}
|
|
1069
|
+
} catch {
|
|
1070
|
+
// Without dataset access the model is pulled without its backings.
|
|
1071
|
+
}
|
|
1072
|
+
return out
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
interface EvalItem {
|
|
1076
|
+
id: string
|
|
1077
|
+
input: unknown
|
|
1078
|
+
expectedOutput: unknown
|
|
1079
|
+
expectations: unknown
|
|
1080
|
+
agentName: string | null
|
|
1081
|
+
/** authored, scenario, message …; absent on an older platform. */
|
|
1082
|
+
subjectKind?: string
|
|
1083
|
+
isGold?: boolean
|
|
1084
|
+
/** The case's name in the project that proposed it; absent on an older platform. */
|
|
1085
|
+
caseName?: string | null
|
|
1086
|
+
}
|
|
1087
|
+
interface EvalDataset { name: string; description?: string | null; totalItems: number; items: EvalItem[] }
|
|
1088
|
+
interface EvalSettings { regressionDatasets?: Record<string, string>; judgeModelSlug?: string | null; judgePanel?: string[] }
|
|
1089
|
+
|
|
1090
|
+
const textOf = (v: unknown): string | null => {
|
|
1091
|
+
if (typeof v === 'string') return v
|
|
1092
|
+
if (v && typeof v === 'object') {
|
|
1093
|
+
const o = v as Record<string, unknown>
|
|
1094
|
+
for (const k of ['query', 'expected', 'text', 'message', 'output', 'answer']) if (typeof o[k] === 'string') return o[k] as string
|
|
1095
|
+
}
|
|
1096
|
+
return null
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
/** Datasets per page and items per dataset page, as `/v1/sync/evaluations` pages them. */
|
|
1100
|
+
const EVAL_DATASETS_PAGE = 50
|
|
1101
|
+
const EVAL_ITEMS_PAGE = 200
|
|
1102
|
+
/** Pages read at most: 10,000 items a dataset, 2,500 datasets. */
|
|
1103
|
+
const EVAL_PAGES = 50
|
|
1104
|
+
|
|
1105
|
+
/**
|
|
1106
|
+
* Every dataset and every item, a page at a time. A platform that does not
|
|
1107
|
+
* page answers every page with the first; a page holding nothing new ends
|
|
1108
|
+
* the reading there.
|
|
1109
|
+
*/
|
|
1110
|
+
async function readEvalDatasets(ws: SyncClient, first: unknown): Promise<EvalDataset[]> {
|
|
1111
|
+
const datasets = rows<EvalDataset>(first)
|
|
1112
|
+
const names = new Set(datasets.map((d) => d.name))
|
|
1113
|
+
for (let page = 2, last = datasets.length; last >= EVAL_DATASETS_PAGE && page <= EVAL_PAGES; page++) {
|
|
1114
|
+
const more = rows<EvalDataset>(await ws.get(`/v1/sync/evaluations?page=${page}`)).filter((d) => !names.has(d.name))
|
|
1115
|
+
for (const d of more) names.add(d.name)
|
|
1116
|
+
datasets.push(...more)
|
|
1117
|
+
last = more.length
|
|
1118
|
+
}
|
|
1119
|
+
for (const ds of datasets) {
|
|
1120
|
+
const seen = new Set(ds.items.map((i) => i.id))
|
|
1121
|
+
for (let page = 2; ds.items.length < ds.totalItems && page <= EVAL_PAGES; page++) {
|
|
1122
|
+
const [next] = rows<EvalDataset>(await ws.get(`/v1/sync/evaluations?dataset=${encodeURIComponent(ds.name)}&itemsPage=${page}`))
|
|
1123
|
+
const fresh = (next?.items ?? []).filter((i) => !seen.has(i.id))
|
|
1124
|
+
if (!fresh.length) break
|
|
1125
|
+
for (const i of fresh) seen.add(i.id)
|
|
1126
|
+
ds.items.push(...fresh)
|
|
1127
|
+
if ((next?.items.length ?? 0) < EVAL_ITEMS_PAGE) break
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
return datasets
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
/** The check types the local runner grades in code, as the platform's expectation grading does. */
|
|
1134
|
+
const LOCAL_CHECKS = new Set(['contains', 'regex', 'tool_called', 'tool_not_called'])
|
|
1135
|
+
|
|
1136
|
+
/**
|
|
1137
|
+
* A scenario's simulated results and expected actions name Functions and
|
|
1138
|
+
* workflows by id or slug; a project names them by slug (`refund-eligibility`),
|
|
1139
|
+
* which the local runtime takes too.
|
|
1140
|
+
*/
|
|
1141
|
+
function scenarioNames(value: unknown, byId: Map<string, { kind: RefKind; name: string }>, unnamed: Set<string>, named: Set<string>): unknown {
|
|
1142
|
+
const name = (id: string) => {
|
|
1143
|
+
const known = byId.get(id)
|
|
1144
|
+
if (known && (known.kind === 'function' || known.kind === 'workflow')) {
|
|
1145
|
+
named.add(`${known.kind}/${known.name}`)
|
|
1146
|
+
return known.name
|
|
1147
|
+
}
|
|
1148
|
+
if (/^[0-9a-f]{8}-[0-9a-f]{4}-/i.test(id)) unnamed.add(id)
|
|
1149
|
+
return id
|
|
1150
|
+
}
|
|
1151
|
+
const v = value as { scenario?: Record<string, unknown>; expect?: Record<string, unknown> }
|
|
1152
|
+
const results = v.scenario?.results && typeof v.scenario.results === 'object' ? (v.scenario.results as Record<string, unknown>) : null
|
|
1153
|
+
const actions = Array.isArray(v.expect?.actions) ? (v.expect!.actions as Array<Record<string, unknown>>) : null
|
|
1154
|
+
return {
|
|
1155
|
+
...v,
|
|
1156
|
+
...(v.scenario ? { scenario: { ...v.scenario, ...(results ? { results: Object.fromEntries(Object.entries(results).map(([k, r]) => [k === '*' ? k : name(k), r])) } : {}) } } : {}),
|
|
1157
|
+
...(v.expect ? { expect: { ...v.expect, ...(actions ? { actions: actions.map((a) => (typeof a.resource === 'string' ? { ...a, resource: name(a.resource) } : a)) } : {}) } } : {}),
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
export interface EvalCaseOut {
|
|
1162
|
+
name: string
|
|
1163
|
+
conversation?: Array<{ user: string }>
|
|
1164
|
+
expected?: string
|
|
1165
|
+
expectedContains?: string[]
|
|
1166
|
+
checks?: Array<Record<string, unknown>>
|
|
1167
|
+
judged?: string[]
|
|
1168
|
+
gold?: true
|
|
1169
|
+
scenario?: Record<string, unknown>
|
|
1170
|
+
expect?: Record<string, unknown>
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
/**
|
|
1174
|
+
* One dataset item as a local test case, or why it stays on the platform:
|
|
1175
|
+
* - a question with its expected answer and checks: `contains` as
|
|
1176
|
+
* `expectedContains`, a pattern or a tool call (made or not) as `checks`,
|
|
1177
|
+
* which the local runner grades the way the platform does; an expectation
|
|
1178
|
+
* only the platform's judge grades is kept in `judged`, in words;
|
|
1179
|
+
* - a scenario (a conversation with a simulated customer) with its spec and
|
|
1180
|
+
* what it expects, which `local test` runs on the local runtime's own
|
|
1181
|
+
* evaluation runner;
|
|
1182
|
+
* - an item that replays a real conversation stays on the platform: its
|
|
1183
|
+
* turns are the workspace's records (design §8).
|
|
1184
|
+
* A gold item says so (`gold: true`).
|
|
1185
|
+
*/
|
|
1186
|
+
export function evalCase(item: EvalItem, name: string, byId: Map<string, { kind: RefKind; name: string }> = new Map()): { case: EvalCaseOut | null; why?: 'conversation' | 'no-question'; judged: number; unnamed: string[]; requires?: string[] } {
|
|
1187
|
+
const input = (item.input ?? null) as Record<string, unknown> | null
|
|
1188
|
+
const gold = item.isGold ? { gold: true as const } : {}
|
|
1189
|
+
if (item.subjectKind === 'scenario' || (input && typeof input.opening === 'string' && typeof input.goal === 'string')) {
|
|
1190
|
+
const { name: _name, ...spec } = (input ?? {}) as Record<string, unknown>
|
|
1191
|
+
const unnamed = new Set<string>()
|
|
1192
|
+
const refs = new Set<string>()
|
|
1193
|
+
const named = scenarioNames({ scenario: spec, expect: (item.expectations ?? {}) as Record<string, unknown> }, byId, unnamed, refs) as { scenario: Record<string, unknown>; expect: Record<string, unknown> }
|
|
1194
|
+
return { case: { name, ...named, ...gold }, judged: 0, unnamed: [...unnamed], requires: [...refs] }
|
|
1195
|
+
}
|
|
1196
|
+
if (item.subjectKind === 'message' || (input && typeof input.conversationId === 'string')) return { case: null, why: 'conversation', judged: 0, unnamed: [] }
|
|
1197
|
+
const question = textOf(item.input)
|
|
1198
|
+
if (!question) return { case: null, why: 'no-question', judged: 0, unnamed: [] }
|
|
1199
|
+
const expectations = Array.isArray(item.expectations) ? (item.expectations as Array<{ text?: unknown; check?: Record<string, unknown> }>) : []
|
|
1200
|
+
const contains: string[] = []
|
|
1201
|
+
const checks: Array<Record<string, unknown>> = []
|
|
1202
|
+
const judged: string[] = []
|
|
1203
|
+
for (const e of expectations) {
|
|
1204
|
+
const type = String(e.check?.type ?? e.check?.kind ?? '')
|
|
1205
|
+
if (e.check && LOCAL_CHECKS.has(type)) {
|
|
1206
|
+
if (type === 'contains' && typeof e.check.value === 'string' && e.check.value) contains.push(e.check.value)
|
|
1207
|
+
else if (type !== 'contains') checks.push({ ...(typeof e.text === 'string' && e.text ? { text: e.text } : {}), ...e.check, type })
|
|
1208
|
+
} else if (typeof e.text === 'string' && e.text.trim()) judged.push(e.text.trim())
|
|
1209
|
+
}
|
|
1210
|
+
const expected = textOf(item.expectedOutput)
|
|
1211
|
+
return {
|
|
1212
|
+
case: {
|
|
1213
|
+
name,
|
|
1214
|
+
conversation: [{ user: question }],
|
|
1215
|
+
...(contains.length ? { expectedContains: contains } : expected ? { expected } : {}),
|
|
1216
|
+
...(checks.length ? { checks } : {}),
|
|
1217
|
+
...(judged.length ? { judged } : {}),
|
|
1218
|
+
...gold,
|
|
1219
|
+
},
|
|
1220
|
+
judged: judged.length,
|
|
1221
|
+
unnamed: [],
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
/**
|
|
1226
|
+
* Each item's case name: the one the project that proposed it gave it (a
|
|
1227
|
+
* change request records it), so names survive a round trip and a reordered
|
|
1228
|
+
* list; otherwise, or when two items claim one name, `<dataset>-<n>` by
|
|
1229
|
+
* position, as before.
|
|
1230
|
+
*/
|
|
1231
|
+
export function caseNames(items: Array<Pick<EvalItem, 'caseName'>>, prefix: string): string[] {
|
|
1232
|
+
const recorded = items.map((it) => (typeof it.caseName === 'string' && it.caseName.trim() ? it.caseName.trim() : null))
|
|
1233
|
+
const claimed = new Map<string, number>()
|
|
1234
|
+
for (const n of recorded) if (n) claimed.set(n, (claimed.get(n) ?? 0) + 1)
|
|
1235
|
+
const used = new Set(recorded.filter((n): n is string => !!n && claimed.get(n) === 1))
|
|
1236
|
+
return items.map((_, i) => {
|
|
1237
|
+
const own = recorded[i]
|
|
1238
|
+
if (own && claimed.get(own) === 1) return own
|
|
1239
|
+
let name = `${prefix}-${String(i + 1).padStart(3, '0')}`
|
|
1240
|
+
for (let k = 2; used.has(name); k++) name = `${prefix}-${String(i + 1).padStart(3, '0')}-${k}`
|
|
1241
|
+
used.add(name)
|
|
1242
|
+
return name
|
|
1243
|
+
})
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
/**
|
|
1247
|
+
* Evaluation datasets as local test cases, one file per dataset under the
|
|
1248
|
+
* Agent its items were made for (`evalCase` says what a case keeps). Every
|
|
1249
|
+
* dataset and item is read, a page at a time. A dataset that is an Agent's
|
|
1250
|
+
* regression dataset in the workspace says so (`regression: true`), and
|
|
1251
|
+
* `local test --regression` runs only those.
|
|
1252
|
+
*/
|
|
1253
|
+
async function readEvaluations(source: SyncSource, lists: Lists, directory: Directory, warnings: string[]): Promise<RemoteItem[]> {
|
|
1254
|
+
const found = await source.workspace.find<unknown>('/v1/sync/evaluations')
|
|
1255
|
+
if (!found) {
|
|
1256
|
+
warnings.push('Evaluations: this platform cannot export evaluation datasets yet')
|
|
1257
|
+
return []
|
|
1258
|
+
}
|
|
1259
|
+
const datasets = await readEvalDatasets(source.workspace, found)
|
|
1260
|
+
const settings = one<EvalSettings | null>(await source.workspace.find<unknown>('/v1/sync/evaluations/settings').catch(() => null))
|
|
1261
|
+
const regression = settings?.regressionDatasets ?? {}
|
|
1262
|
+
const byId = invert(directory)
|
|
1263
|
+
const slugByName = new Map(lists.agents.map((a) => [a.name, a.agentId]))
|
|
1264
|
+
const out: RemoteItem[] = []
|
|
1265
|
+
for (const ds of datasets) {
|
|
1266
|
+
const byAgent = new Map<string, EvalItem[]>()
|
|
1267
|
+
for (const it of ds.items) {
|
|
1268
|
+
const slug = (it.agentName && slugByName.get(it.agentName)) || lists.agents.find((a) => ds.name.toLowerCase().includes(a.agentId))?.agentId
|
|
1269
|
+
if (!slug) continue
|
|
1270
|
+
byAgent.set(slug, [...(byAgent.get(slug) ?? []), it])
|
|
1271
|
+
}
|
|
1272
|
+
const unplaced = ds.items.length - [...byAgent.values()].reduce((n, l) => n + l.length, 0)
|
|
1273
|
+
for (const [slug, items] of byAgent) {
|
|
1274
|
+
const notes: string[] = []
|
|
1275
|
+
let noQuestion = 0
|
|
1276
|
+
let conversations = 0
|
|
1277
|
+
let judged = 0
|
|
1278
|
+
let gold = 0
|
|
1279
|
+
const unnamed = new Set<string>()
|
|
1280
|
+
const requires = new Set<string>([`agent/${slug}`])
|
|
1281
|
+
const names = caseNames(items, slugify(ds.name))
|
|
1282
|
+
const cases = items.flatMap((it, i) => {
|
|
1283
|
+
const made = evalCase(it, names[i]!, byId)
|
|
1284
|
+
if (!made.case) {
|
|
1285
|
+
if (made.why === 'conversation') conversations++
|
|
1286
|
+
else noQuestion++
|
|
1287
|
+
return []
|
|
1288
|
+
}
|
|
1289
|
+
judged += made.judged
|
|
1290
|
+
if (made.case.gold) gold++
|
|
1291
|
+
for (const id of made.unnamed) unnamed.add(id)
|
|
1292
|
+
for (const r of made.requires ?? []) requires.add(r)
|
|
1293
|
+
return [made.case]
|
|
1294
|
+
})
|
|
1295
|
+
if (conversations) notes.push(`${conversations} case${conversations === 1 ? ' replays' : 's replay'} a real conversation from the workspace, so ${conversations === 1 ? 'it stays' : 'they stay'} on the platform`)
|
|
1296
|
+
if (noQuestion) notes.push(`${noQuestion} item${noQuestion === 1 ? '' : 's'} had no question the local runner can send`)
|
|
1297
|
+
if (judged) notes.push(`${judged} expectation${judged === 1 ? ' is' : 's are'} graded by the platform’s judge; ${judged === 1 ? 'it is' : 'they are'} kept in the file (\`judged\`) and not graded locally`)
|
|
1298
|
+
if (gold) notes.push(`${gold} gold case${gold === 1 ? '' : 's'} (human-certified)`)
|
|
1299
|
+
if (unnamed.size) notes.push(`A scenario names ${unnamed.size} Function${unnamed.size === 1 ? ' or workflow' : 's or workflows'} by a platform id the workspace no longer has`)
|
|
1300
|
+
if (ds.totalItems > ds.items.length) notes.push(`The first ${ds.items.length} of ${ds.totalItems} items were read`)
|
|
1301
|
+
if (unplaced) notes.push(`${unplaced} item${unplaced === 1 ? '' : 's'} not tied to an Agent stayed on the platform`)
|
|
1302
|
+
const isRegression = regression[slug] === ds.name
|
|
1303
|
+
if (isRegression) notes.push(`${slug}’s regression dataset in the workspace: \`local test --regression\` runs it`)
|
|
1304
|
+
const file = `agents/${slug}/evals/${slugify(ds.name)}.yaml`
|
|
1305
|
+
const header = `# Test cases from the platform's evaluation dataset "${ds.name}" (frontera local sync).\n`
|
|
1306
|
+
const doc = { ...(isRegression ? { regression: true } : {}), cases }
|
|
1307
|
+
out.push(item('evaluation', `${slug}.${slugify(ds.name)}`, `${ds.name} → ${slug}`, { [file]: header + stringifyYaml(doc, { lineWidth: 0 }) }, { notes, requires: [...requires].sort() }))
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1310
|
+
return out
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
/**
|
|
1314
|
+
* Everything the workspace has, as the project would hold it. `warnings`
|
|
1315
|
+
* collects what could not be read (a kind the key may not see, a platform
|
|
1316
|
+
* too old for an export), so the plan says so instead of failing.
|
|
1317
|
+
*/
|
|
1318
|
+
export async function readWorkspace(source: SyncSource, opts: ReadOptions = {}): Promise<{ items: RemoteItem[]; warnings: string[]; notes: string[]; directory: Directory }> {
|
|
1319
|
+
const warnings: string[] = []
|
|
1320
|
+
const notes: string[] = []
|
|
1321
|
+
const { directory, lists } = await buildDirectory(source, warnings)
|
|
1322
|
+
const want = (k: SyncKind) => !opts.kinds || opts.kinds.has(k)
|
|
1323
|
+
// Each Plugin's tools, read once and only when a Plugin file or an Agent's tool settings need them.
|
|
1324
|
+
let toolsRead: Promise<PluginTools> | null = null
|
|
1325
|
+
const tools = () => (toolsRead ??= readPluginTools(source, lists.apps, warnings))
|
|
1326
|
+
const pluginsWanted = want('plugin') && lists.apps.some((a) => a.install && (a.catalog?.kind ?? '').toLowerCase() === 'mcp' && wanted(opts, `plugin/${a.install.installName}`))
|
|
1327
|
+
const groups = await Promise.all([
|
|
1328
|
+
want('blueprint')
|
|
1329
|
+
? quietly(warnings, 'Blueprint', () => readBlueprint(source, opts.blueprint ?? 'workspace', warnings, notes, { lists, directory }, { draft: [...(opts.drafts ?? [])].some((r) => r === 'blueprint' || r.startsWith('blueprint/')) }), [])
|
|
1330
|
+
: [],
|
|
1331
|
+
want('skill') ? readSkills(source, lists, opts, warnings) : [],
|
|
1332
|
+
want('function') ? readFunctions(source, lists, opts, warnings) : [],
|
|
1333
|
+
pluginsWanted ? tools().then((t) => readPlugins(lists, opts, t)) : [],
|
|
1334
|
+
want('knowledge') ? readKnowledge(source, lists, opts, warnings) : [],
|
|
1335
|
+
want('workflow') ? readWorkflows(source, lists, directory, opts, warnings) : [],
|
|
1336
|
+
want('agent') ? readAgents(source, lists, directory, opts, warnings, tools) : [],
|
|
1337
|
+
want('evaluation') ? quietly(warnings, 'Evaluations', () => readEvaluations(source, lists, directory, warnings), []) : [],
|
|
1338
|
+
// Slide themes: the workspace's own; a platform without the route has none to bring.
|
|
1339
|
+
want('theme')
|
|
1340
|
+
? quietly(warnings, 'Slide themes', async () => {
|
|
1341
|
+
const found = await source.workspace.find<unknown>('/v1/sync/slide-themes')
|
|
1342
|
+
return found === null ? [] : readThemes(source.workspace, rows<ThemeRow>(found), (ref) => wanted(opts, ref), warnings)
|
|
1343
|
+
}, [])
|
|
1344
|
+
: [],
|
|
1345
|
+
// Chat Apps: the experience an Agent is used in; a platform without the route has none to bring.
|
|
1346
|
+
want('chat-app')
|
|
1347
|
+
? quietly(warnings, 'Chat Apps', async () => {
|
|
1348
|
+
const found = await source.workspace.find<unknown>('/v1/sync/chat-apps')
|
|
1349
|
+
return found === null ? [] : readChatApps(rows<ChatAppRow>(found), directory, (ref) => wanted(opts, ref), notes)
|
|
1350
|
+
}, [])
|
|
1351
|
+
: [],
|
|
1352
|
+
// Custom Apps: what each is and the Functions it may start; their code is their own project.
|
|
1353
|
+
want('app')
|
|
1354
|
+
? quietly(warnings, 'Apps', async () => {
|
|
1355
|
+
const found = await source.workspace.find<unknown>('/v1/sync/apps')
|
|
1356
|
+
return found === null ? [] : readApps(rows<CustomAppRow>(found), (ref) => wanted(opts, ref))
|
|
1357
|
+
}, [])
|
|
1358
|
+
: [],
|
|
1359
|
+
// The organization's brand, read-only here; one item read whole, as the Blueprint is.
|
|
1360
|
+
want('brand')
|
|
1361
|
+
? quietly(warnings, 'the organization’s brand', async () => {
|
|
1362
|
+
const found = await source.workspace.find<BrandRow>('/v1/sync/brand')
|
|
1363
|
+
return found === null ? [] : readBrand(source.workspace, (found as { data?: BrandRow }).data ?? found, warnings)
|
|
1364
|
+
}, [])
|
|
1365
|
+
: [],
|
|
1366
|
+
// What the workspace installed from the library: a record of where its items came from.
|
|
1367
|
+
want('library')
|
|
1368
|
+
? quietly(warnings, 'library installations', async () => {
|
|
1369
|
+
const found = await source.workspace.find<unknown>('/v1/sync/library')
|
|
1370
|
+
return found === null ? [] : readLibrary(rows<LibraryRow>(found), directory)
|
|
1371
|
+
}, [])
|
|
1372
|
+
: [],
|
|
1373
|
+
])
|
|
1374
|
+
const items = groups.flat()
|
|
1375
|
+
noteConnections(items, lists)
|
|
1376
|
+
noteActions(items, directory)
|
|
1377
|
+
// What each item needs against what the local runtime can do (§8), with the Actions this read brought.
|
|
1378
|
+
const actions = new Map(items.filter((i) => i.kind === 'blueprint' && i.name.startsWith('actions/')).flatMap((i) => Object.values(i.files).map((text) => [i.name.slice('actions/'.length), actionFit((parseYaml(text) ?? {}) as Record<string, unknown>)] as const)))
|
|
1379
|
+
const titles = new Map(lists.apps.flatMap((a) => (a.install && a.catalog?.displayName ? [[a.install.installName, a.catalog.displayName] as const] : [])))
|
|
1380
|
+
for (const it of items) it.portability = portabilityOf(it.kind, it.files, { connections: connectionNames(lists), builtIns: builtInKinds(lists), context: { actions, titles } })
|
|
1381
|
+
if (!source.organization && items.some((i) => i.unresolved.length)) {
|
|
1382
|
+
notes.push(directory.model?.size
|
|
1383
|
+
? 'Governed Actions are named only with an organization key (they are organization-wide); an Action step shows as an id until you connect one'
|
|
1384
|
+
: 'Models and governed Actions are named only with an organization key (both are organization-wide); an LLM step’s model or an Action step shows as an id until you connect one')
|
|
1385
|
+
}
|
|
1386
|
+
return { items, warnings, notes, directory }
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
/**
|
|
1390
|
+
* A built-in connection (Google Sheets, BigQuery, GitHub …) is signed in on
|
|
1391
|
+
* the platform with OAuth or a service account, so it is never pulled as a
|
|
1392
|
+
* Plugin file. Say so on each Agent and workflow that uses one, rather than
|
|
1393
|
+
* leaving a reference the pull silently could not bring.
|
|
1394
|
+
*/
|
|
1395
|
+
/** The install names of the built-in connections (every installed Plugin that is not an MCP server). */
|
|
1396
|
+
export function connectionNames(lists: Pick<Lists, 'apps'>): Set<string> {
|
|
1397
|
+
return new Set(lists.apps.flatMap((a) => (a.install && (a.catalog?.kind ?? '').toLowerCase() !== 'mcp' ? [a.install.installName] : [])))
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
/**
|
|
1401
|
+
* The built-in Plugins that are not connections to another system, by install
|
|
1402
|
+
* name: the Documentation plugin, which a local runtime serves from its own
|
|
1403
|
+
* documentation, and the workspace's analytics, which stays on the platform.
|
|
1404
|
+
*/
|
|
1405
|
+
export function builtInKinds(lists: Pick<Lists, 'apps'>): Map<string, BuiltInKind> {
|
|
1406
|
+
const out = new Map<string, BuiltInKind>()
|
|
1407
|
+
for (const a of lists.apps) {
|
|
1408
|
+
const kind = (a.catalog?.kind ?? '').toLowerCase()
|
|
1409
|
+
if (!a.install) continue
|
|
1410
|
+
if (kind === 'frontera_documentation') out.set(a.install.installName, 'documentation')
|
|
1411
|
+
if (kind === 'rill') out.set(a.install.installName, 'analytics')
|
|
1412
|
+
}
|
|
1413
|
+
return out
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
export function noteConnections(items: RemoteItem[], lists: Pick<Lists, 'apps'>): void {
|
|
1417
|
+
const builtIns = builtInKinds(lists)
|
|
1418
|
+
const connections = new Map(
|
|
1419
|
+
lists.apps.flatMap((a) => (a.install && (a.catalog?.kind ?? '').toLowerCase() !== 'mcp' ? [[`plugin/${a.install.installName}`, a.install.installName] as [string, string]] : [])),
|
|
1420
|
+
)
|
|
1421
|
+
for (const it of items) {
|
|
1422
|
+
const all = (it.requires ?? []).flatMap((r) => (connections.has(r) ? [connections.get(r)!] : []))
|
|
1423
|
+
const used = all.filter((n) => !builtIns.has(n))
|
|
1424
|
+
if (all.some((n) => builtIns.get(n) === 'documentation')) it.notes.push('Uses Frontera’s documentation: locally its documentation tools read the documentation of the local runtime’s release')
|
|
1425
|
+
if (all.some((n) => builtIns.get(n) === 'analytics')) {
|
|
1426
|
+
it.notes.push(it.kind === 'workflow'
|
|
1427
|
+
? 'Uses the workspace’s analytics tools, which stay on the platform: a local runtime has no analytics, so locally a step on them stops and says so'
|
|
1428
|
+
: 'Uses the workspace’s analytics tools, which stay on the platform: a local runtime has no analytics, so locally it runs without them')
|
|
1429
|
+
}
|
|
1430
|
+
if (!used.length) continue
|
|
1431
|
+
const names = used.join(', ')
|
|
1432
|
+
it.notes.push(`Uses ${names}: ${used.length === 1 ? 'a connection' : 'connections'} signed in on the platform, not pulled; locally a stand-in answers ${used.length === 1 ? 'its' : 'their'} tools with “not connected”`)
|
|
1433
|
+
}
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
/**
|
|
1437
|
+
* A governed Action comes down with the Blueprint when an organization key is
|
|
1438
|
+
* connected, and the local runtime deploys it (D3), so a workflow or Agent
|
|
1439
|
+
* naming one (`action/<apiName>`) runs locally. Without the organization key
|
|
1440
|
+
* the Actions cannot be read: say which this item needs and how to bring
|
|
1441
|
+
* them. More than three are counted, with the names after the sentence.
|
|
1442
|
+
*/
|
|
1443
|
+
export function noteActions(items: RemoteItem[], directory?: Directory): void {
|
|
1444
|
+
for (const it of items) {
|
|
1445
|
+
const names = [...new Set((it.requires ?? []).flatMap((r) => (r.startsWith('action/') ? [r.slice('action/'.length)] : r.startsWith('blueprint/actions/') ? [r.slice('blueprint/actions/'.length)] : [])))]
|
|
1446
|
+
// Named and in the organization's Blueprint: it comes with the Blueprint and runs locally.
|
|
1447
|
+
const missing = directory?.action ? names.filter((n) => !directory.action!.has(n)) : names
|
|
1448
|
+
if (!missing.length) continue
|
|
1449
|
+
const many = missing.length > 3
|
|
1450
|
+
const list = missing.join(', ')
|
|
1451
|
+
const subject = it.kind === 'workflow' ? 'this workflow' : 'this Agent'
|
|
1452
|
+
if (directory?.action) {
|
|
1453
|
+
it.notes.push(many ? `Runs ${missing.length} governed Actions that aren’t in the organization’s Blueprint: ${list}` : `Runs governed Action${missing.length === 1 ? '' : 's'} ${list}, not in the organization’s Blueprint`)
|
|
1454
|
+
continue
|
|
1455
|
+
}
|
|
1456
|
+
const what = many ? `${missing.length} governed Actions` : `governed Action${missing.length === 1 ? '' : 's'} ${list}`
|
|
1457
|
+
it.notes.push(`${subject.charAt(0).toUpperCase()}${subject.slice(1)} runs ${what}: add an organization key to bring ${missing.length === 1 ? 'it' : 'them'} with the Blueprint${many ? `: ${list}` : ''}`)
|
|
1458
|
+
}
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
/**
|
|
1462
|
+
* A workflow node whose Action could not be named keeps its platform id; say
|
|
1463
|
+
* how many, and why, so the plan does not leave an id nobody can place.
|
|
1464
|
+
*/
|
|
1465
|
+
export function noteUnnamedActions(it: RemoteItem, doc: unknown, directory: Directory): void {
|
|
1466
|
+
const known = new Set(directory.action?.values() ?? [])
|
|
1467
|
+
const unnamed = new Set(governedActionIds(doc).flatMap((a) => (known.has(a.id) ? [] : [a.id])))
|
|
1468
|
+
if (!unnamed.size) return
|
|
1469
|
+
const why = directory.action ? 'they are not among the organization’s Blueprint Actions' : 'naming them takes an organization key that may read Blueprint Actions'
|
|
1470
|
+
it.notes.push(`Runs ${unnamed.size} governed Action${unnamed.size === 1 ? '' : 's'} left as platform ids (${why}), so this workflow does not run locally until ${unnamed.size === 1 ? 'it is' : 'they are'} named`)
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
/** The kinds a reference names, as items a pull can bring along. */
|
|
1474
|
+
export const REF_TO_KIND: Partial<Record<RefKind, SyncKind>> = {
|
|
1475
|
+
function: 'function',
|
|
1476
|
+
workflow: 'workflow',
|
|
1477
|
+
agent: 'agent',
|
|
1478
|
+
knowledge: 'knowledge',
|
|
1479
|
+
plugin: 'plugin',
|
|
1480
|
+
skill: 'skill',
|
|
1481
|
+
}
|
|
1482
|
+
|