@frontera-sdk/cli 1.52.28 → 1.52.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/package.json +5 -4
  2. package/src/commands/local/data.ts +60 -0
  3. package/src/commands/local/dev.ts +361 -18
  4. package/src/commands/local/index-commands.ts +11 -3
  5. package/src/commands/local/init.ts +88 -7
  6. package/src/commands/local/sync-request.ts +750 -0
  7. package/src/commands/local/sync.ts +941 -0
  8. package/src/commands/skill/bundle-commands.ts +9 -6
  9. package/src/errors.ts +4 -1
  10. package/src/exit.ts +3 -0
  11. package/src/flag-help.ts +48 -0
  12. package/src/local/local-actions.ts +306 -0
  13. package/src/local/local-adapt.ts +216 -0
  14. package/src/local/local-agent-files.ts +9 -2
  15. package/src/local/local-agent-grants.ts +125 -0
  16. package/src/local/local-agent-name.ts +71 -0
  17. package/src/local/local-applied.ts +334 -3
  18. package/src/local/local-apps.ts +156 -0
  19. package/src/local/local-blueprint-outline.ts +465 -0
  20. package/src/local/local-brand.ts +123 -0
  21. package/src/local/local-build-summary.ts +106 -0
  22. package/src/local/local-chat-apps.ts +170 -0
  23. package/src/local/local-dev-references.ts +1132 -0
  24. package/src/local/local-dev-sync.ts +171 -7
  25. package/src/local/local-docs.ts +53 -5
  26. package/src/local/local-env.ts +2 -0
  27. package/src/local/local-function-draft.ts +33 -0
  28. package/src/local/local-function-shared.ts +65 -0
  29. package/src/local/local-function-state.ts +34 -0
  30. package/src/local/local-knowledge-documents.ts +302 -0
  31. package/src/local/local-lessons.ts +278 -0
  32. package/src/local/local-library.ts +124 -0
  33. package/src/local/local-media-sets.ts +31 -0
  34. package/src/local/local-object-sets.ts +181 -0
  35. package/src/local/local-objects.ts +159 -0
  36. package/src/local/local-operating.ts +9 -5
  37. package/src/local/local-org-settings.ts +118 -0
  38. package/src/local/local-portability.ts +346 -0
  39. package/src/local/local-project.ts +6 -2
  40. package/src/local/local-quality-checks.ts +171 -0
  41. package/src/local/local-scaffold.ts +14 -11
  42. package/src/local/local-schemas.ts +33 -2
  43. package/src/local/local-secrets.ts +306 -0
  44. package/src/local/local-stand-ins.ts +442 -0
  45. package/src/local/local-studio-server.ts +23 -2
  46. package/src/local/local-sync-base.ts +237 -0
  47. package/src/local/local-sync-client.ts +93 -0
  48. package/src/local/local-sync-link.ts +122 -0
  49. package/src/local/local-sync-merge.ts +395 -0
  50. package/src/local/local-sync-names.ts +169 -0
  51. package/src/local/local-sync-plan.ts +717 -0
  52. package/src/local/local-sync-push.ts +986 -0
  53. package/src/local/local-sync-refs.ts +221 -0
  54. package/src/local/local-sync-remote.ts +1482 -0
  55. package/src/local/local-sync-snapshot.ts +526 -0
  56. package/src/local/local-synthetic-data.ts +374 -0
  57. package/src/local/local-test-runner.ts +172 -14
  58. package/src/local/local-themes.ts +201 -0
  59. package/src/local/local-workflow-state.ts +131 -0
@@ -0,0 +1,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
+