@frontera-sdk/cli 1.52.28 → 1.52.30
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -4
- package/src/commands/local/data.ts +60 -0
- package/src/commands/local/dev.ts +361 -18
- package/src/commands/local/index-commands.ts +11 -3
- package/src/commands/local/init.ts +88 -7
- package/src/commands/local/sync-request.ts +750 -0
- package/src/commands/local/sync.ts +941 -0
- package/src/commands/skill/bundle-commands.ts +9 -6
- package/src/errors.ts +4 -1
- package/src/exit.ts +3 -0
- package/src/flag-help.ts +48 -0
- package/src/local/local-actions.ts +306 -0
- package/src/local/local-adapt.ts +216 -0
- package/src/local/local-agent-files.ts +9 -2
- package/src/local/local-agent-grants.ts +125 -0
- package/src/local/local-agent-name.ts +71 -0
- package/src/local/local-applied.ts +334 -3
- package/src/local/local-apps.ts +156 -0
- package/src/local/local-blueprint-outline.ts +465 -0
- package/src/local/local-brand.ts +123 -0
- package/src/local/local-build-summary.ts +106 -0
- package/src/local/local-chat-apps.ts +170 -0
- package/src/local/local-dev-references.ts +1132 -0
- package/src/local/local-dev-sync.ts +171 -7
- package/src/local/local-docs.ts +53 -5
- package/src/local/local-env.ts +2 -0
- package/src/local/local-function-draft.ts +33 -0
- package/src/local/local-function-shared.ts +65 -0
- package/src/local/local-function-state.ts +34 -0
- package/src/local/local-knowledge-documents.ts +302 -0
- package/src/local/local-lessons.ts +278 -0
- package/src/local/local-library.ts +124 -0
- package/src/local/local-media-sets.ts +31 -0
- package/src/local/local-object-sets.ts +181 -0
- package/src/local/local-objects.ts +159 -0
- package/src/local/local-operating.ts +9 -5
- package/src/local/local-org-settings.ts +118 -0
- package/src/local/local-portability.ts +346 -0
- package/src/local/local-project.ts +6 -2
- package/src/local/local-quality-checks.ts +171 -0
- package/src/local/local-scaffold.ts +14 -11
- package/src/local/local-schemas.ts +33 -2
- package/src/local/local-secrets.ts +306 -0
- package/src/local/local-stand-ins.ts +442 -0
- package/src/local/local-studio-server.ts +23 -2
- package/src/local/local-sync-base.ts +237 -0
- package/src/local/local-sync-client.ts +93 -0
- package/src/local/local-sync-link.ts +122 -0
- package/src/local/local-sync-merge.ts +395 -0
- package/src/local/local-sync-names.ts +169 -0
- package/src/local/local-sync-plan.ts +717 -0
- package/src/local/local-sync-push.ts +986 -0
- package/src/local/local-sync-refs.ts +221 -0
- package/src/local/local-sync-remote.ts +1482 -0
- package/src/local/local-sync-snapshot.ts +526 -0
- package/src/local/local-synthetic-data.ts +374 -0
- package/src/local/local-test-runner.ts +172 -14
- package/src/local/local-themes.ts +201 -0
- package/src/local/local-workflow-state.ts +131 -0
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
import { parse as parseYaml } from 'yaml'
|
|
2
|
+
|
|
3
|
+
import { unnamedObjectBindings } from './local-objects'
|
|
4
|
+
import { governedActionIds, referencesIn } from './local-sync-refs'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Whether an item can run on the local runtime, and how (design §8). Computed
|
|
8
|
+
* from two declarations, never from error handling: what the item **needs**
|
|
9
|
+
* (governed Actions, a built-in connection, the platform's media files, the
|
|
10
|
+
* Agent's computer, calculated fields, markings), read from its files, and
|
|
11
|
+
* what the local runtime **can do**. The fetch stores the result beside each
|
|
12
|
+
* item in the snapshot; `local dev` lists the same adaptations in the build;
|
|
13
|
+
* the Blueprint outline (`local-blueprint-outline.ts`) and `resolveBlueprint`
|
|
14
|
+
* read the object-type rule from here. One set of rules, said once.
|
|
15
|
+
*
|
|
16
|
+
* portable applies as written
|
|
17
|
+
* adapted applies after listed adaptations; the files stay as they are
|
|
18
|
+
* stubbed depends on something platform-only; the runtime gets a stand-in
|
|
19
|
+
* platform-only cannot run here; named, so nothing silently refers to it
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export type PortabilityClass = 'portable' | 'adapted' | 'stubbed' | 'platform-only'
|
|
23
|
+
|
|
24
|
+
/** What the local runtime can do. */
|
|
25
|
+
export interface RuntimeCapabilities {
|
|
26
|
+
/**
|
|
27
|
+
* An Agent's computer. The service starts one only on Daytona
|
|
28
|
+
* (`agentComputerConfigured`: `DAYTONA_API_KEY` and `DAYTONA_API_URL`), which
|
|
29
|
+
* the local runtime's environment never carries; its Agents run without one
|
|
30
|
+
* and attachments go to the model.
|
|
31
|
+
*/
|
|
32
|
+
computer: boolean
|
|
33
|
+
/**
|
|
34
|
+
* The platform's media files (a `media_reference` property). A type that
|
|
35
|
+
* reads a Media Set's catalog gets an empty Media Set of the same name
|
|
36
|
+
* instead (Stubbed); one that reads its extracted text or passages is left off.
|
|
37
|
+
*/
|
|
38
|
+
media: boolean
|
|
39
|
+
/** Built-in connections signed in with OAuth or a service account (Google Sheets, BigQuery…). */
|
|
40
|
+
connections: boolean
|
|
41
|
+
/** Calculated properties (a location from latitude and longitude); `blueprint apply` cannot create one yet. */
|
|
42
|
+
calculatedFields: boolean
|
|
43
|
+
/** Governed Actions: `local` deploys those that write the Blueprint's own records (D3). */
|
|
44
|
+
governedActions: 'local' | 'none'
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const LOCAL_RUNTIME: RuntimeCapabilities = {
|
|
48
|
+
computer: false,
|
|
49
|
+
media: false,
|
|
50
|
+
connections: false,
|
|
51
|
+
calculatedFields: false,
|
|
52
|
+
governedActions: 'local',
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** One thing an item needs that the runtime may not have. */
|
|
56
|
+
export type Need =
|
|
57
|
+
| { kind: 'action'; name: string }
|
|
58
|
+
/** A governed Action named only by the platform's id: reading its name takes an organization key. */
|
|
59
|
+
| { kind: 'action-unnamed'; count: number }
|
|
60
|
+
| { kind: 'connection'; name: string }
|
|
61
|
+
/** The workspace's Documentation plugin: served here from the runtime's documentation. */
|
|
62
|
+
| { kind: 'documentation'; name: string }
|
|
63
|
+
/** The workspace's analytics Plugin: it reads the workspace's analytics, which a local runtime does not have. */
|
|
64
|
+
| { kind: 'analytics'; name: string }
|
|
65
|
+
| { kind: 'computer' }
|
|
66
|
+
| { kind: 'media' }
|
|
67
|
+
/** A media-backed object type over a Media Set's catalog: an empty Media Set of that name stands in. */
|
|
68
|
+
| { kind: 'media-stub'; set: string }
|
|
69
|
+
| { kind: 'calculated'; fields: string[] }
|
|
70
|
+
| { kind: 'markings'; dataset: string }
|
|
71
|
+
/** A governed Action whose effect reaches outside the runtime (a transition, a command, another system). */
|
|
72
|
+
| { kind: 'external-effect' }
|
|
73
|
+
/** An Agent's Context and data the pull could not read (`bindings.json` `blueprintUnread`). */
|
|
74
|
+
| { kind: 'context-unread' }
|
|
75
|
+
/** A Chat App: published only with published Agents, and every local Agent is a draft. */
|
|
76
|
+
| { kind: 'app-draft' }
|
|
77
|
+
/** A Custom App: its code is its own project, deployed on its own; `external` when it is hosted on its own domain. */
|
|
78
|
+
| { kind: 'app-code'; external?: boolean }
|
|
79
|
+
/** An Agent's object tools whose object definition the pull couldn't name (an older platform). */
|
|
80
|
+
| { kind: 'objects-unnamed'; count: number }
|
|
81
|
+
|
|
82
|
+
export interface Portability {
|
|
83
|
+
class: PortabilityClass
|
|
84
|
+
/** Each adaptation in plain words, most important first. */
|
|
85
|
+
adaptations: string[]
|
|
86
|
+
needs: Need[]
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const RANK: Record<PortabilityClass, number> = { portable: 0, adapted: 1, stubbed: 2, 'platform-only': 3 }
|
|
90
|
+
|
|
91
|
+
/** How the local runtime runs governed Actions it holds, by apiName: deployed, or switched off. */
|
|
92
|
+
export type ActionFits = ReadonlyMap<string, ActionFit>
|
|
93
|
+
|
|
94
|
+
export interface Context {
|
|
95
|
+
caps?: RuntimeCapabilities
|
|
96
|
+
/** The governed Actions known locally (the project's `blueprint/actions/`), and whether each runs here. */
|
|
97
|
+
actions?: ActionFits
|
|
98
|
+
/** Built-in connections as people know them ("Google Sheets"), by install name. */
|
|
99
|
+
titles?: ReadonlyMap<string, string>
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** One need against the runtime: its class and what it means, in words. Null when the runtime has it. */
|
|
103
|
+
export function judge(need: Need, ctx: Context = {}): { class: PortabilityClass; text: string } | null {
|
|
104
|
+
const caps = ctx.caps ?? LOCAL_RUNTIME
|
|
105
|
+
switch (need.kind) {
|
|
106
|
+
case 'action': {
|
|
107
|
+
if (caps.governedActions === 'none') return { class: 'platform-only', text: `governed Action ${need.name} runs on the platform only` }
|
|
108
|
+
const fit = ctx.actions?.get(need.name)
|
|
109
|
+
// Unknown here: what the build says about it decides; at fetch it comes down with the Blueprint.
|
|
110
|
+
if (!fit || fit.runsLocally) return null
|
|
111
|
+
return { class: 'adapted', text: `governed Action ${need.name} is switched off here: ${fit.reason}` }
|
|
112
|
+
}
|
|
113
|
+
case 'action-unnamed':
|
|
114
|
+
return { class: 'platform-only', text: `runs ${need.count === 1 ? 'a governed Action' : `${need.count} governed Actions`} this key can’t name: add an organization key to bring ${need.count === 1 ? 'it' : 'them'}` }
|
|
115
|
+
case 'connection':
|
|
116
|
+
return caps.connections ? null : { class: 'stubbed', text: `${ctx.titles?.get(need.name) ?? need.name} isn’t connected here: a stand-in answers its tools with “not connected”` }
|
|
117
|
+
case 'documentation':
|
|
118
|
+
return null
|
|
119
|
+
case 'analytics':
|
|
120
|
+
return { class: 'adapted', text: 'analytics tools stay on the platform: a local runtime has no analytics' }
|
|
121
|
+
case 'computer':
|
|
122
|
+
return caps.computer ? null : { class: 'adapted', text: 'runs without its computer: attachments go to the model' }
|
|
123
|
+
case 'media':
|
|
124
|
+
return caps.media ? null : { class: 'platform-only', text: 'reads the platform’s media files, so the local runtime leaves it off' }
|
|
125
|
+
case 'media-stub':
|
|
126
|
+
return caps.media ? null : { class: 'stubbed', text: `its files stay on the platform: an empty Media Set, ${need.set}, stands in for them here` }
|
|
127
|
+
case 'calculated':
|
|
128
|
+
return caps.calculatedFields
|
|
129
|
+
? null
|
|
130
|
+
: { class: 'adapted', text: `${need.fields.join(', ')} ${need.fields.length === 1 ? 'is' : 'are'} left off: calculated fields aren’t applied locally yet` }
|
|
131
|
+
case 'markings':
|
|
132
|
+
return { class: 'adapted', text: `reads every row: its markings come from the platform’s dataset, and ${need.dataset} has none` }
|
|
133
|
+
case 'external-effect':
|
|
134
|
+
return { class: 'stubbed', text: 'switched off here: it changes a system outside the local runtime' }
|
|
135
|
+
case 'app-draft':
|
|
136
|
+
return { class: 'adapted', text: 'saved as the owner’s draft here and opened as its preview, its Agents running their drafts: a Chat App publishes only published Agents, and the local runtime’s Agents are drafts' }
|
|
137
|
+
case 'app-code':
|
|
138
|
+
return { class: 'adapted', text: `saved here with the Functions it may start, without its code: an App’s code is its own project, deployed to this runtime on its own${need.external ? '; it is hosted on its own domain, whose sign-in stays on the platform' : ''}` }
|
|
139
|
+
case 'objects-unnamed':
|
|
140
|
+
return { class: 'adapted', text: `goes without ${need.count === 1 ? 'one of its object tools' : `${need.count} of its object tools`} here: the pull couldn’t read ${need.count === 1 ? 'its object definition' : 'their object definitions'} on the platform` }
|
|
141
|
+
case 'context-unread':
|
|
142
|
+
return { class: 'adapted', text: 'its Context and data couldn’t be read on the platform: read access to the pulled object types is assumed here until it can be read' }
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** The class an item gets from its needs: the most limiting one wins. */
|
|
147
|
+
export function classify(needs: Need[], ctx: Context = {}): Portability {
|
|
148
|
+
let cls: PortabilityClass = 'portable'
|
|
149
|
+
const judged = needs.flatMap((n) => {
|
|
150
|
+
const j = judge(n, ctx)
|
|
151
|
+
return j ? [j] : []
|
|
152
|
+
})
|
|
153
|
+
judged.sort((a, b) => RANK[b.class] - RANK[a.class])
|
|
154
|
+
for (const j of judged) if (RANK[j.class] > RANK[cls]) cls = j.class
|
|
155
|
+
return { class: cls, adaptations: [...new Set(judged.map((j) => j.text))], needs }
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** "Runs locally without its computer; Google Sheets isn’t connected here." */
|
|
159
|
+
export function portabilitySentence(p: Portability): string {
|
|
160
|
+
if (!p.adaptations.length) return 'Runs locally as written'
|
|
161
|
+
const [first, ...rest] = p.adaptations
|
|
162
|
+
return `${first!.charAt(0).toUpperCase()}${first!.slice(1)}${rest.length ? `; ${rest.join('; ')}` : ''}`
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ── Object types ─────────────────────────────────────────────────────────────
|
|
166
|
+
|
|
167
|
+
type Doc = Record<string, any>
|
|
168
|
+
|
|
169
|
+
/** What the local runtime does with an object type. */
|
|
170
|
+
export interface ObjectTypeFit {
|
|
171
|
+
/** It reads the platform's media files (a `media_reference` property) other than a Media Set's catalog: left off the runtime. */
|
|
172
|
+
leftOff: boolean
|
|
173
|
+
/** It reads a Media Set's catalog: the runtime holds it over an empty Media Set of this API name. */
|
|
174
|
+
mediaStub?: string | null
|
|
175
|
+
/** Calculated properties, by apiName: the runtime holds the type without them. */
|
|
176
|
+
calculated: string[]
|
|
177
|
+
/** Its rows are read by markings from the platform's dataset: here it reads every row. */
|
|
178
|
+
readsEveryRow: boolean
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* The rule `resolveBlueprint` applies to one object type. `hasLocalDataset`
|
|
183
|
+
* says whether the project has the dataset's local file (`datasets/<name>.json`).
|
|
184
|
+
*/
|
|
185
|
+
/** The columns a Media Set's catalog publishes (the service's `MEDIA_CATALOG_COLUMNS`). */
|
|
186
|
+
const MEDIA_CATALOG_COLUMNS = new Set(['media_item_id', 'path', 'mime_type', 'size_bytes', 'created_at', 'version', 'is_latest', 'media_reference', '_security_markings'])
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* The Media Set whose catalog an object type reads, by its API name: the
|
|
190
|
+
* catalog's dataset is `media_<set>`, and every column the type reads is one
|
|
191
|
+
* the catalog publishes. Null for a type over a set's extracted text or
|
|
192
|
+
* passages (other columns), or a dataset named otherwise.
|
|
193
|
+
*/
|
|
194
|
+
export function mediaCatalogSet(doc: Doc): string | null {
|
|
195
|
+
const m = typeof doc.backing?.dataset === 'string' ? /^media_([A-Za-z][A-Za-z0-9_]*)$/.exec(doc.backing.dataset) : null
|
|
196
|
+
if (!m) return null
|
|
197
|
+
const mapping = (doc.backing?.mapping ?? {}) as Record<string, unknown>
|
|
198
|
+
const properties = (Array.isArray(doc.properties) ? doc.properties : []) as Array<{ apiName?: string; column?: unknown }>
|
|
199
|
+
const columns = properties.map((p) => (typeof p.column === 'string' ? p.column : typeof mapping[p.apiName ?? ''] === 'string' ? (mapping[p.apiName ?? ''] as string) : null))
|
|
200
|
+
return columns.every((c) => c !== null && MEDIA_CATALOG_COLUMNS.has(c)) ? m[1]! : null
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export function objectTypeFit(doc: Doc, hasLocalDataset: (dataset: string) => boolean): ObjectTypeFit {
|
|
204
|
+
const properties = (Array.isArray(doc.properties) ? doc.properties : []) as Array<{ apiName?: string; valueType?: string; calculation?: unknown }>
|
|
205
|
+
if (properties.some((p) => p.valueType === 'media_reference')) {
|
|
206
|
+
const set = mediaCatalogSet(doc)
|
|
207
|
+
return set ? { leftOff: false, mediaStub: set, calculated: [], readsEveryRow: false } : { leftOff: true, calculated: [], readsEveryRow: false }
|
|
208
|
+
}
|
|
209
|
+
const calculated = properties.filter((p) => p.calculation).map((p) => String(p.apiName ?? ''))
|
|
210
|
+
const dataset = doc.backing?.dataset
|
|
211
|
+
const readsEveryRow = doc.rowAccess === 'markings' && !doc.markingsColumn && typeof dataset === 'string' && hasLocalDataset(dataset)
|
|
212
|
+
return { leftOff: false, calculated, readsEveryRow }
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function objectTypeNeeds(doc: Doc, hasLocalDataset: (dataset: string) => boolean): Need[] {
|
|
216
|
+
const fit = objectTypeFit(doc, hasLocalDataset)
|
|
217
|
+
if (fit.leftOff) return [{ kind: 'media' }]
|
|
218
|
+
if (fit.mediaStub) return [{ kind: 'media-stub', set: fit.mediaStub }]
|
|
219
|
+
const needs: Need[] = []
|
|
220
|
+
if (fit.calculated.length) {
|
|
221
|
+
const properties = (Array.isArray(doc.properties) ? doc.properties : []) as Array<{ apiName?: string; displayName?: string }>
|
|
222
|
+
needs.push({ kind: 'calculated', fields: fit.calculated.map((api) => properties.find((p) => p.apiName === api)?.displayName ?? api) })
|
|
223
|
+
}
|
|
224
|
+
if (fit.readsEveryRow) needs.push({ kind: 'markings', dataset: String(doc.backing?.dataset) })
|
|
225
|
+
return needs
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ── Governed Actions ─────────────────────────────────────────────────────────
|
|
229
|
+
|
|
230
|
+
export interface ActionFit {
|
|
231
|
+
/** It writes only the Blueprint's own records, so the local runtime deploys it. */
|
|
232
|
+
runsLocally: boolean
|
|
233
|
+
/** Why not, in words. */
|
|
234
|
+
reason?: string
|
|
235
|
+
/** The object type it acts on. */
|
|
236
|
+
subject: string | null
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const ONTOLOGY_EDITS = new Set(['create', 'update', 'delete'])
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* What a governed Action does when it runs, from its file. An Action whose
|
|
243
|
+
* effect creates, updates or deletes the subject's properties is an edit over
|
|
244
|
+
* the Blueprint's records: locally that writes over the runtime's own
|
|
245
|
+
* datasets and reaches nothing else, so it is deployed (D3). A transition, a
|
|
246
|
+
* command, or an edit with nothing to set would be bound to a connector on the
|
|
247
|
+
* platform, which calls a real system: here it is published switched off.
|
|
248
|
+
*/
|
|
249
|
+
export function actionFit(doc: Doc): ActionFit {
|
|
250
|
+
const subject = typeof doc.subject?.objectType === 'string' ? doc.subject.objectType : null
|
|
251
|
+
const effect = typeof doc.effect?.kind === 'string' ? doc.effect.kind : null
|
|
252
|
+
if (!effect || !ONTOLOGY_EDITS.has(effect)) return { runsLocally: false, reason: 'it changes a system outside the local runtime', subject }
|
|
253
|
+
const changes = (Array.isArray(doc.businessOutcomes) ? doc.businessOutcomes : []).flatMap((o: Doc) => (Array.isArray(o?.expectedChanges) ? o.expectedChanges : []))
|
|
254
|
+
const sets = changes.some((c: Doc) => c?.kind === 'property_set' && c?.value?.kind === 'input')
|
|
255
|
+
if (effect !== 'delete' && !sets) return { runsLocally: false, reason: 'it sets no field from its inputs, so there is nothing to write locally', subject }
|
|
256
|
+
return { runsLocally: true, subject }
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export function actionNeeds(doc: Doc): Need[] {
|
|
260
|
+
return actionFit(doc).runsLocally ? [] : [{ kind: 'external-effect' }]
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// ── Agents and workflows ─────────────────────────────────────────────────────
|
|
264
|
+
|
|
265
|
+
const COMPUTER_ON = new Set(['on-demand', 'eager'])
|
|
266
|
+
|
|
267
|
+
function parse(path: string, text: string): unknown {
|
|
268
|
+
try {
|
|
269
|
+
return /\.json$/.test(path) ? JSON.parse(text) : parseYaml(text)
|
|
270
|
+
} catch {
|
|
271
|
+
return null
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* What an Agent's or workflow's files need: the governed Actions they name
|
|
277
|
+
* (and any still named by the platform's id), the built-in connections among
|
|
278
|
+
* the Plugins they name (`connections`, install names), and for an Agent its
|
|
279
|
+
* computer when its availability asks for one.
|
|
280
|
+
*/
|
|
281
|
+
/** What a built-in Plugin is, when it is not a connection to another system. */
|
|
282
|
+
export type BuiltInKind = 'documentation' | 'analytics'
|
|
283
|
+
|
|
284
|
+
export function itemNeeds(kind: string, files: Record<string, string>, connections: ReadonlySet<string> = new Set(), builtIns: ReadonlyMap<string, BuiltInKind> = new Map()): Need[] {
|
|
285
|
+
const needs: Need[] = []
|
|
286
|
+
const actions = new Set<string>()
|
|
287
|
+
const plugins = new Set<string>()
|
|
288
|
+
let unnamed = 0
|
|
289
|
+
for (const [path, text] of Object.entries(files)) {
|
|
290
|
+
if (!/\.(json|ya?ml)$/.test(path)) continue
|
|
291
|
+
const doc = parse(path, text)
|
|
292
|
+
if (!doc) continue
|
|
293
|
+
for (const ref of referencesIn(doc)) {
|
|
294
|
+
if (ref.kind === 'action') actions.add(ref.name)
|
|
295
|
+
if (ref.kind === 'plugin' && connections.has(ref.name)) plugins.add(ref.name)
|
|
296
|
+
}
|
|
297
|
+
unnamed += governedActionIds(doc).length
|
|
298
|
+
if (kind === 'agent' && /\/agent\.json$/.test(path)) {
|
|
299
|
+
const availability = (doc as { config?: { features?: { computerAvailability?: unknown } } }).config?.features?.computerAvailability
|
|
300
|
+
if (typeof availability === 'string' && COMPUTER_ON.has(availability)) needs.push({ kind: 'computer' })
|
|
301
|
+
}
|
|
302
|
+
if (kind === 'agent' && /\/agent\.json$/.test(path)) {
|
|
303
|
+
const unnamed = unnamedObjectBindings(doc)
|
|
304
|
+
if (unnamed) needs.push({ kind: 'objects-unnamed', count: unnamed })
|
|
305
|
+
}
|
|
306
|
+
if (kind === 'agent' && /\/bindings\.json$/.test(path) && (doc as { blueprintUnread?: unknown }).blueprintUnread === true && !Array.isArray((doc as { blueprint?: unknown }).blueprint)) needs.push({ kind: 'context-unread' })
|
|
307
|
+
}
|
|
308
|
+
if (kind === 'chat-app') needs.push({ kind: 'app-draft' })
|
|
309
|
+
if (kind === 'app') {
|
|
310
|
+
const doc = Object.values(files)[0]
|
|
311
|
+
let external = false
|
|
312
|
+
try {
|
|
313
|
+
external = !!doc && (JSON.parse(doc) as { hosting?: unknown }).hosting === 'external'
|
|
314
|
+
} catch {
|
|
315
|
+
// A file that does not parse is applied as it is; the apply says why it fails.
|
|
316
|
+
}
|
|
317
|
+
needs.push({ kind: 'app-code', ...(external ? { external } : {}) })
|
|
318
|
+
}
|
|
319
|
+
for (const name of [...actions].sort()) needs.push({ kind: 'action', name })
|
|
320
|
+
if (unnamed) needs.push({ kind: 'action-unnamed', count: unnamed })
|
|
321
|
+
for (const name of [...plugins].sort()) needs.push({ kind: builtIns.get(name) ?? 'connection', name })
|
|
322
|
+
return needs
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* An item from the workspace, by its files: Blueprint files by their own
|
|
327
|
+
* rule, everything else by what it names. `hasLocalDataset` answers for the
|
|
328
|
+
* project the item lands in.
|
|
329
|
+
*/
|
|
330
|
+
export function portabilityOf(
|
|
331
|
+
kind: string,
|
|
332
|
+
files: Record<string, string>,
|
|
333
|
+
opts: { connections?: ReadonlySet<string>; builtIns?: ReadonlyMap<string, BuiltInKind>; hasLocalDataset?: (dataset: string) => boolean; context?: Context } = {},
|
|
334
|
+
): Portability {
|
|
335
|
+
if (kind === 'blueprint') {
|
|
336
|
+
const needs = Object.entries(files).flatMap(([path, text]): Need[] => {
|
|
337
|
+
const doc = parse(path, text) as Doc | null
|
|
338
|
+
if (!doc || typeof doc !== 'object') return []
|
|
339
|
+
if (path.startsWith('blueprint/object-types/')) return objectTypeNeeds(doc, opts.hasLocalDataset ?? (() => true))
|
|
340
|
+
if (path.startsWith('blueprint/actions/')) return actionNeeds(doc)
|
|
341
|
+
return []
|
|
342
|
+
})
|
|
343
|
+
return classify(needs, opts.context)
|
|
344
|
+
}
|
|
345
|
+
return classify(itemNeeds(kind, files, opts.connections, opts.builtIns), opts.context)
|
|
346
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto'
|
|
2
2
|
import { existsSync } from 'node:fs'
|
|
3
|
+
import { homedir } from 'node:os'
|
|
3
4
|
import { basename, dirname, join, resolve } from 'node:path'
|
|
4
5
|
|
|
5
6
|
/**
|
|
@@ -27,10 +28,13 @@ export interface LocalProject {
|
|
|
27
28
|
export const PROJECT_FILES = ['frontera.yaml', 'frontera.solution.yaml'] as const
|
|
28
29
|
const MARKERS = [...PROJECT_FILES, '.frontera'] as const
|
|
29
30
|
|
|
30
|
-
export function findSolutionRoot(cwd: string): string {
|
|
31
|
+
export function findSolutionRoot(cwd: string, home: string = homedir()): string {
|
|
31
32
|
let dir = resolve(cwd)
|
|
32
33
|
for (;;) {
|
|
33
|
-
|
|
34
|
+
// `~/.frontera` is the CLI's own settings, not a project: in the home folder only a project file counts,
|
|
35
|
+
// or every new folder under it (`~/Frontera/acme`) would be taken for part of a project rooted at home.
|
|
36
|
+
const markers = dir === resolve(home) ? PROJECT_FILES : MARKERS
|
|
37
|
+
if (markers.some((m) => existsSync(join(dir, m)))) return dir
|
|
34
38
|
const parent = dirname(dir)
|
|
35
39
|
if (parent === dir) return resolve(cwd)
|
|
36
40
|
dir = parent
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A knowledge base's quality checks (the platform's knowledge eval
|
|
5
|
+
* fixtures): the questions its retrieval should answer, each with the
|
|
6
|
+
* documents it should find, the words it should return and the answer the
|
|
7
|
+
* returned passages should support. They come down with the knowledge base,
|
|
8
|
+
* in `knowledge/<name>/quality-checks.yaml` beside `knowledge.json`, so a
|
|
9
|
+
* quality check runs against the local runtime's copy. Their runs and scores
|
|
10
|
+
* stay on the platform (sync v2 design §8, records).
|
|
11
|
+
*
|
|
12
|
+
* A check names its documents by path in the knowledge base's folder
|
|
13
|
+
* (`Policies/Returns/terms.md`), not by the platform's ids: the local
|
|
14
|
+
* runtime's copy of a document has its own. A document the runtime doesn't
|
|
15
|
+
* hold (documents are brought only when asked) is left off that check
|
|
16
|
+
* locally, said once.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export const QUALITY_CHECKS_FILE = 'quality-checks.yaml'
|
|
20
|
+
|
|
21
|
+
export const qualityChecksPath = (kb: string) => `knowledge/${kb}/${QUALITY_CHECKS_FILE}`
|
|
22
|
+
|
|
23
|
+
const HEADER = '# Quality checks for this knowledge base, from the workspace (frontera local sync pull).\n# Each question names the documents its search should find; frontera local dev gives them to the local runtime. Runs stay on the platform.\n'
|
|
24
|
+
|
|
25
|
+
export interface QualityCheck {
|
|
26
|
+
question: string
|
|
27
|
+
/** Documents the search should find, by path in the knowledge base's folder. */
|
|
28
|
+
documents?: string[]
|
|
29
|
+
/** Words the returned passages should contain. */
|
|
30
|
+
keywords?: string[]
|
|
31
|
+
/** The answer the returned passages should support. */
|
|
32
|
+
answer?: string
|
|
33
|
+
/** Generated from the documents on the platform, not written by a person. */
|
|
34
|
+
generated?: true
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** A fixture as `GET /v1/workspace-knowledge/:id/eval/fixtures` lists it. */
|
|
38
|
+
export interface FixtureRow {
|
|
39
|
+
id: string
|
|
40
|
+
query: string
|
|
41
|
+
expectedSourceIds?: string[] | null
|
|
42
|
+
expectedKeywords?: string[] | null
|
|
43
|
+
groundTruthAnswer?: string | null
|
|
44
|
+
source?: string | null
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** A document as `GET …/sources` lists it. */
|
|
48
|
+
export interface SourceRow {
|
|
49
|
+
id: string
|
|
50
|
+
fileName?: string | null
|
|
51
|
+
folderId?: string | null
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const rowsOf = <T>(body: unknown, key: string): T[] => {
|
|
55
|
+
const data = (body as { data?: unknown } | null)?.data ?? body
|
|
56
|
+
const list = Array.isArray(data) ? data : (data as Record<string, unknown> | null)?.[key]
|
|
57
|
+
return (Array.isArray(list) ? list : []) as T[]
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export const fixtureRows = (body: unknown) => rowsOf<FixtureRow>(body, 'fixtures')
|
|
61
|
+
export const sourceRows = (body: unknown) => rowsOf<SourceRow>(body, 'sources')
|
|
62
|
+
|
|
63
|
+
/** A document's path under its knowledge base's folder (as `local-knowledge-documents.ts` writes it). */
|
|
64
|
+
const documentPath = (folder: string | null | undefined, fileName: string) => (folder ? `${folder}/${fileName}` : fileName)
|
|
65
|
+
|
|
66
|
+
/** Each document's path by its id, as the knowledge base's folders file it. */
|
|
67
|
+
export function sourcePaths(sources: SourceRow[], folders: ReadonlyMap<string, string>): Map<string, string> {
|
|
68
|
+
return new Map(sources.flatMap((s) => (s.id && s.fileName ? [[s.id, documentPath(s.folderId ? folders.get(s.folderId) : null, s.fileName)] as const] : [])))
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The platform's fixtures as the file holds them; `unnamed` counts the documents no path could be found for. */
|
|
72
|
+
export function checksFromFixtures(fixtures: FixtureRow[], paths: ReadonlyMap<string, string>): { checks: QualityCheck[]; unnamed: number } {
|
|
73
|
+
let unnamed = 0
|
|
74
|
+
const checks = fixtures.map((f): QualityCheck => {
|
|
75
|
+
const documents = (f.expectedSourceIds ?? []).flatMap((id) => {
|
|
76
|
+
const path = paths.get(id)
|
|
77
|
+
if (!path) unnamed += 1
|
|
78
|
+
return path ? [path] : []
|
|
79
|
+
})
|
|
80
|
+
return {
|
|
81
|
+
question: f.query,
|
|
82
|
+
...(documents.length ? { documents } : {}),
|
|
83
|
+
...(f.expectedKeywords?.length ? { keywords: [...f.expectedKeywords] } : {}),
|
|
84
|
+
...(f.groundTruthAnswer ? { answer: f.groundTruthAnswer } : {}),
|
|
85
|
+
...(f.source === 'synthetic' ? { generated: true as const } : {}),
|
|
86
|
+
}
|
|
87
|
+
})
|
|
88
|
+
// In the order of their questions: the platform lists them newest first, which a file need not follow.
|
|
89
|
+
return { checks: checks.sort((a, b) => a.question.localeCompare(b.question)), unnamed }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function qualityChecksText(checks: QualityCheck[]): string {
|
|
93
|
+
return HEADER + stringifyYaml({ checks }, { lineWidth: 0 })
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The checks a file holds, in order; a question once. Throws on a file that doesn't parse. */
|
|
97
|
+
export function readQualityChecks(text: string): QualityCheck[] {
|
|
98
|
+
const doc = parseYaml(text) as { checks?: unknown } | null
|
|
99
|
+
const out: QualityCheck[] = []
|
|
100
|
+
const strings = (v: unknown) => (Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string' && !!x.trim()).map((x) => x.trim()) : [])
|
|
101
|
+
for (const c of Array.isArray(doc?.checks) ? doc!.checks : []) {
|
|
102
|
+
const row = (c ?? {}) as Record<string, unknown>
|
|
103
|
+
const question = typeof row.question === 'string' ? row.question.trim() : ''
|
|
104
|
+
if (!question || out.some((x) => x.question === question)) continue
|
|
105
|
+
const documents = strings(row.documents)
|
|
106
|
+
const keywords = strings(row.keywords)
|
|
107
|
+
out.push({
|
|
108
|
+
question,
|
|
109
|
+
...(documents.length ? { documents } : {}),
|
|
110
|
+
...(keywords.length ? { keywords } : {}),
|
|
111
|
+
...(typeof row.answer === 'string' && row.answer.trim() ? { answer: row.answer.trim() } : {}),
|
|
112
|
+
...(row.generated === true ? { generated: true as const } : {}),
|
|
113
|
+
})
|
|
114
|
+
}
|
|
115
|
+
return out
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** What a check is as the fixture routes take it, its documents by this runtime's ids. */
|
|
119
|
+
function fixtureBody(check: QualityCheck, ids: ReadonlyMap<string, string>): { query: string; expectedSourceIds: string[]; expectedKeywords: string[]; groundTruthAnswer: string } {
|
|
120
|
+
return {
|
|
121
|
+
query: check.question,
|
|
122
|
+
expectedSourceIds: (check.documents ?? []).flatMap((p) => (ids.has(p) ? [ids.get(p)!] : [])),
|
|
123
|
+
expectedKeywords: check.keywords ?? [],
|
|
124
|
+
groundTruthAnswer: check.answer ?? '',
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const sameList = (a: readonly string[] | null | undefined, b: readonly string[]) => JSON.stringify([...(a ?? [])].sort()) === JSON.stringify([...b].sort())
|
|
129
|
+
|
|
130
|
+
type Call = (method: string, path: string, body?: unknown) => Promise<{ status: number; body: unknown }>
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Give a knowledge base on the runtime the file's quality checks: a question
|
|
134
|
+
* the runtime lacks is added, one whose documents, words or answer differ is
|
|
135
|
+
* updated, and one the file no longer has is removed. `documentIds` is the
|
|
136
|
+
* runtime's documents by path. Returns the lines for the build.
|
|
137
|
+
*/
|
|
138
|
+
export async function applyQualityChecks(kbId: string, kbName: string, checks: QualityCheck[], documentIds: ReadonlyMap<string, string>, call: Call): Promise<{ ok: boolean; lines: string[] }> {
|
|
139
|
+
const base = `/v1/workspace-knowledge/${encodeURIComponent(kbId)}/eval/fixtures`
|
|
140
|
+
const listed = await call('GET', base)
|
|
141
|
+
if (listed.status >= 400) return { ok: false, lines: [` warning: Knowledge base ${kbName}’s quality checks weren’t applied: the runtime answered ${listed.status}`] }
|
|
142
|
+
const here = fixtureRows(listed.body)
|
|
143
|
+
const lines: string[] = []
|
|
144
|
+
let added = 0
|
|
145
|
+
let changed = 0
|
|
146
|
+
let removed = 0
|
|
147
|
+
const missing = new Set<string>()
|
|
148
|
+
for (const check of checks) {
|
|
149
|
+
for (const p of check.documents ?? []) if (!documentIds.has(p)) missing.add(p)
|
|
150
|
+
const body = fixtureBody(check, documentIds)
|
|
151
|
+
const found = here.find((f) => f.query === check.question)
|
|
152
|
+
if (!found) {
|
|
153
|
+
const made = await call('POST', base, body)
|
|
154
|
+
if (made.status >= 400) lines.push(` warning: Knowledge base ${kbName}: the quality check “${check.question}” wasn’t added (${made.status})`)
|
|
155
|
+
else added += 1
|
|
156
|
+
continue
|
|
157
|
+
}
|
|
158
|
+
if (sameList(found.expectedSourceIds, body.expectedSourceIds) && sameList(found.expectedKeywords, body.expectedKeywords) && (found.groundTruthAnswer ?? '') === body.groundTruthAnswer) continue
|
|
159
|
+
const put = await call('PUT', `${base}/${encodeURIComponent(found.id)}`, body)
|
|
160
|
+
if (put.status >= 400) lines.push(` warning: Knowledge base ${kbName}: the quality check “${check.question}” wasn’t updated (${put.status})`)
|
|
161
|
+
else changed += 1
|
|
162
|
+
}
|
|
163
|
+
for (const f of here.filter((x) => !checks.some((c) => c.question === x.query))) {
|
|
164
|
+
const gone = await call('DELETE', `${base}/${encodeURIComponent(f.id)}`)
|
|
165
|
+
if (gone.status < 400) removed += 1
|
|
166
|
+
}
|
|
167
|
+
const counts = [added ? `${added} added` : '', changed ? `${changed} updated` : '', removed ? `${removed} removed` : ''].filter(Boolean).join(', ')
|
|
168
|
+
lines.unshift(` ${checks.length} quality check${checks.length === 1 ? '' : 's'} for knowledge base ${kbName}${counts ? ` (${counts})` : ''}`)
|
|
169
|
+
if (missing.size) lines.push(` warning: Knowledge base ${kbName}’s quality checks name ${missing.size} document${missing.size === 1 ? '' : 's'} this runtime doesn’t have, so they check without ${missing.size === 1 ? 'it' : 'them'}: bring the documents to check them here`)
|
|
170
|
+
return { ok: true, lines }
|
|
171
|
+
}
|
|
@@ -316,7 +316,8 @@ const GITIGNORE = `node_modules/
|
|
|
316
316
|
.env
|
|
317
317
|
`
|
|
318
318
|
|
|
319
|
-
|
|
319
|
+
/** `samples: false` leaves out the example Agent, workflow, Function and object type: a project started from a workspace gets the workspace's own. */
|
|
320
|
+
export function solutionScaffoldFiles(name: string, opts: { agentModel?: string; samples?: boolean } = {}): Record<string, string> {
|
|
320
321
|
const sdk = vendored.files as Record<string, string>
|
|
321
322
|
const out: Record<string, string> = {}
|
|
322
323
|
for (const rel of AUTOMATION_SDK_FILES) {
|
|
@@ -331,15 +332,17 @@ export function solutionScaffoldFiles(name: string, opts: { agentModel?: string
|
|
|
331
332
|
out['.gitignore'] = GITIGNORE
|
|
332
333
|
out['.mcp.json'] = DOCS_MCP
|
|
333
334
|
out['.cursor/mcp.json'] = DOCS_MCP
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
335
|
+
if (opts.samples !== false) {
|
|
336
|
+
out['agents/assistant/agent.json'] = AGENT(name, opts.agentModel)
|
|
337
|
+
out['agents/assistant/operating.json'] = OPERATING
|
|
338
|
+
out['agents/assistant/evals/regression.yaml'] = EVALS
|
|
339
|
+
out['workflows/hello.yaml'] = WORKFLOW
|
|
340
|
+
out['functions/word-count.ts'] = FUNCTION
|
|
341
|
+
out['functions/word-count.test.ts'] = FUNCTION_TEST
|
|
342
|
+
out['datasets/tickets.json'] = DATASET
|
|
343
|
+
out['datasets/tickets.csv'] = DATASET_ROWS
|
|
344
|
+
out['blueprint/object-types/Ticket.yaml'] = OBJECT_TYPE
|
|
345
|
+
}
|
|
343
346
|
out['package.json'] =
|
|
344
347
|
JSON.stringify(
|
|
345
348
|
{
|
|
@@ -386,7 +389,7 @@ export function solutionScaffoldFiles(name: string, opts: { agentModel?: string
|
|
|
386
389
|
* are. An AGENTS.md that is still the CLI's generic one is replaced anyway:
|
|
387
390
|
* nobody has changed it, and the project's version contains it.
|
|
388
391
|
*/
|
|
389
|
-
export function writeSolutionScaffold(target: string, name: string, force: boolean, opts: { agentModel?: string } = {}): { written: string[]; skipped: string[] } {
|
|
392
|
+
export function writeSolutionScaffold(target: string, name: string, force: boolean, opts: { agentModel?: string; samples?: boolean } = {}): { written: string[]; skipped: string[] } {
|
|
390
393
|
const written: string[] = []
|
|
391
394
|
const skipped: string[] = []
|
|
392
395
|
for (const [rel, content] of Object.entries(solutionScaffoldFiles(name, opts))) {
|
|
@@ -70,13 +70,44 @@ const EVALS_SCHEMA: JsonSchema = {
|
|
|
70
70
|
items: { type: 'string', minLength: 1 },
|
|
71
71
|
description: 'Phrases the last reply must contain, ignoring case. When present, expected is not checked.',
|
|
72
72
|
},
|
|
73
|
+
checks: {
|
|
74
|
+
type: 'array',
|
|
75
|
+
description: 'Checks on the last reply and the tools the Agent called, graded as the platform grades them; every one must pass.',
|
|
76
|
+
items: {
|
|
77
|
+
type: 'object',
|
|
78
|
+
properties: {
|
|
79
|
+
type: { enum: ['contains', 'regex', 'tool_called', 'tool_not_called'] },
|
|
80
|
+
value: { type: 'string', description: 'contains: the phrase; regex: the pattern.' },
|
|
81
|
+
flags: { type: 'string', description: 'regex flags; i when absent.' },
|
|
82
|
+
tool: { type: 'string', description: 'tool_called, tool_not_called: the tool’s name.' },
|
|
83
|
+
argsSubset: { type: 'object', description: 'The arguments the call must carry.' },
|
|
84
|
+
mode: { enum: ['exact', 'ignore', 'subset', 'superset'], description: 'How argsSubset is compared; superset when absent.' },
|
|
85
|
+
text: { type: 'string', description: 'What the check means, in words.' },
|
|
86
|
+
},
|
|
87
|
+
required: ['type'],
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
judged: { type: 'array', items: { type: 'string' }, description: 'Expectations only the platform’s judge grades; kept, not graded locally.' },
|
|
91
|
+
gold: { type: 'boolean', description: 'Human-certified on the platform.' },
|
|
92
|
+
scenario: {
|
|
93
|
+
type: 'object',
|
|
94
|
+
description: 'A conversation with a simulated customer (persona, goal, opening, maxTurns, verified, results, note), run on the local runtime’s evaluation runner.',
|
|
95
|
+
required: ['goal', 'opening'],
|
|
96
|
+
},
|
|
97
|
+
expect: { type: 'object', description: 'What a scenario expects: outcome, actions, noActions.' },
|
|
73
98
|
savedFrom: { type: 'string', description: 'Where the case was saved from; Studio writes it.' },
|
|
74
99
|
},
|
|
75
|
-
required: ['name'
|
|
76
|
-
anyOf: [
|
|
100
|
+
required: ['name'],
|
|
101
|
+
anyOf: [
|
|
102
|
+
{ required: ['conversation', 'expected'] },
|
|
103
|
+
{ required: ['conversation', 'expectedContains'] },
|
|
104
|
+
{ required: ['conversation', 'checks'] },
|
|
105
|
+
{ required: ['scenario'] },
|
|
106
|
+
],
|
|
77
107
|
additionalProperties: false,
|
|
78
108
|
},
|
|
79
109
|
},
|
|
110
|
+
regression: { type: 'boolean', description: 'This file is the Agent’s regression dataset in the workspace; `local test --regression` runs only these.' },
|
|
80
111
|
},
|
|
81
112
|
required: ['cases'],
|
|
82
113
|
additionalProperties: false,
|