@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,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
- if (MARKERS.some((m) => existsSync(join(dir, m)))) return dir
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
- export function solutionScaffoldFiles(name: string, opts: { agentModel?: string } = {}): Record<string, string> {
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
- out['agents/assistant/agent.json'] = AGENT(name, opts.agentModel)
335
- out['agents/assistant/operating.json'] = OPERATING
336
- out['agents/assistant/evals/regression.yaml'] = EVALS
337
- out['workflows/hello.yaml'] = WORKFLOW
338
- out['functions/word-count.ts'] = FUNCTION
339
- out['functions/word-count.test.ts'] = FUNCTION_TEST
340
- out['datasets/tickets.json'] = DATASET
341
- out['datasets/tickets.csv'] = DATASET_ROWS
342
- out['blueprint/object-types/Ticket.yaml'] = OBJECT_TYPE
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', 'conversation'],
76
- anyOf: [{ required: ['expected'] }, { required: ['expectedContains'] }],
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,