@frontera-sdk/cli 1.53.23 → 1.53.24

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 (52) hide show
  1. package/package.json +4 -4
  2. package/src/api/knowledge-base-api.ts +230 -0
  3. package/src/commands/knowledge/index-commands.ts +6 -6
  4. package/src/commands/knowledge-base/index-commands.ts +328 -0
  5. package/src/commands/local/check.ts +12 -0
  6. package/src/commands/local/dev.ts +64 -2
  7. package/src/commands/local/index-commands.ts +34 -10
  8. package/src/commands/local/init.ts +11 -2
  9. package/src/commands/local/schema.ts +1 -1
  10. package/src/commands/local/sync-request.ts +135 -29
  11. package/src/commands/local/sync.ts +40 -25
  12. package/src/commands/registry.ts +4 -1
  13. package/src/commands/types.ts +6 -0
  14. package/src/flag-help.ts +10 -4
  15. package/src/forge/touches.ts +29 -0
  16. package/src/local/local-applied.ts +28 -0
  17. package/src/local/local-build-summary.ts +1 -0
  18. package/src/local/local-component-health.ts +7 -2
  19. package/src/local/local-dev-references.ts +6 -2
  20. package/src/local/local-dev-sync.ts +43 -4
  21. package/src/local/local-docker.ts +16 -1
  22. package/src/local/local-env.ts +19 -0
  23. package/src/local/local-file-checks.ts +56 -8
  24. package/src/local/local-knowledge-base-apply.ts +714 -0
  25. package/src/local/local-knowledge-base-files.ts +195 -0
  26. package/src/local/local-knowledge-base-pull.ts +458 -0
  27. package/src/local/local-knowledge-base-sources-off.ts +67 -0
  28. package/src/local/local-person-steps.ts +2 -1
  29. package/src/local/local-personas.ts +64 -7
  30. package/src/local/local-portability.ts +27 -0
  31. package/src/local/local-ports.ts +4 -2
  32. package/src/local/local-project-secrets.ts +30 -5
  33. package/src/local/local-scaffold.ts +2 -0
  34. package/src/local/local-schema-check.ts +3 -1
  35. package/src/local/local-schemas.ts +11 -1
  36. package/src/local/local-search-index.ts +92 -0
  37. package/src/local/local-secrets.ts +7 -2
  38. package/src/local/local-seed.ts +17 -0
  39. package/src/local/local-state.ts +7 -5
  40. package/src/local/local-studio-server.ts +3 -3
  41. package/src/local/local-supervisor.ts +45 -8
  42. package/src/local/local-sync-link.ts +16 -0
  43. package/src/local/local-sync-names.ts +10 -1
  44. package/src/local/local-sync-plan.ts +44 -5
  45. package/src/local/local-sync-push.ts +165 -34
  46. package/src/local/local-sync-refs.ts +3 -1
  47. package/src/local/local-sync-remote.ts +44 -12
  48. package/src/local/local-sync-snapshot.ts +156 -12
  49. package/src/main.ts +13 -0
  50. package/src/mcp/mcp-server.ts +3 -0
  51. package/src/mcp/tools/docs-tools.ts +1 -1
  52. package/src/mcp/tools/knowledge-base-tools.ts +85 -0
@@ -0,0 +1,67 @@
1
+ import { existsSync, readdirSync, readFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+
4
+ import { KNOWLEDGE_BASE_DIR, KNOWLEDGE_BASE_FILE, KNOWLEDGE_BASE_SETTINGS_FILE, KNOWLEDGE_BASE_SOURCES_DIR, type KnowledgeBaseFile, type KnowledgeBaseSettingsFile, type KnowledgeSourcePolicy } from './local-knowledge-base-files'
5
+
6
+ /** What Apply on the platform would refuse, said before anything is sent, in the shape of the platform's person needs. */
7
+ export interface SourcesOffNeed {
8
+ step: string
9
+ ref: string
10
+ code: 'knowledge_sources_off'
11
+ message: string
12
+ fix: string
13
+ }
14
+
15
+ const SETTINGS = `${KNOWLEDGE_BASE_DIR}/${KNOWLEDGE_BASE_SETTINGS_FILE}`
16
+
17
+ function readJson<T>(path: string): T | null {
18
+ try {
19
+ return JSON.parse(readFileSync(path, 'utf8')) as T
20
+ } catch {
21
+ return null
22
+ }
23
+ }
24
+
25
+ /** Whether the policy lets a base of this scope have sources, as the platform decides it. */
26
+ function allows(policy: KnowledgeSourcePolicy, scope: KnowledgeBaseFile['scope']): boolean {
27
+ if (policy.apiSources === 'off') return false
28
+ if (policy.apiSources === 'organization') return scope === 'organization'
29
+ // shortcut: chosen_workspaces names slugs and the link keeps the workspace's id, so it is taken as allowing; read the slug if this misleads.
30
+ return true
31
+ }
32
+
33
+ /**
34
+ * Each knowledge base with files in `sources/` whose sources the project's
35
+ * Knowledge settings (`knowledge-base/settings.json`) keep off for it, or,
36
+ * with no `sources` in that file, whose sources Apply needs allowed: a new
37
+ * workspace keeps API sources off, and Apply refuses such a base.
38
+ */
39
+ export function sourcesOffNeeds(root: string): SourcesOffNeed[] {
40
+ const dir = join(root, KNOWLEDGE_BASE_DIR)
41
+ if (!existsSync(dir)) return []
42
+ const policy = readJson<KnowledgeBaseSettingsFile>(join(dir, KNOWLEDGE_BASE_SETTINGS_FILE))?.sources
43
+ const needs: SourcesOffNeed[] = []
44
+ for (const folder of readdirSync(dir, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name).sort()) {
45
+ const base = readJson<KnowledgeBaseFile>(join(dir, folder, KNOWLEDGE_BASE_FILE))
46
+ if (!base) continue
47
+ const sourcesDir = join(dir, folder, KNOWLEDGE_BASE_SOURCES_DIR)
48
+ const sources = existsSync(sourcesDir) ? readdirSync(sourcesDir).filter((f) => f.endsWith('.json')).sort() : []
49
+ if (!sources.length || (policy && allows(policy, base.scope))) continue
50
+ const place = base.scope === 'organization' ? 'organization knowledge bases' : 'this workspace'
51
+ const files = sources.map((f) => `${KNOWLEDGE_BASE_SOURCES_DIR}/${f}`).join(', ')
52
+ const why = policy
53
+ ? `${SETTINGS} keeps API sources ${policy.apiSources === 'off' ? 'off' : 'to organization knowledge bases'}`
54
+ : null
55
+ needs.push({
56
+ step: `Knowledge base ${base.name ?? folder}`,
57
+ ref: `${KNOWLEDGE_BASE_DIR}/${folder}`,
58
+ code: 'knowledge_sources_off',
59
+ // Without pulled settings the platform's policy is unknown here: most people cannot read it, so say what Apply depends on.
60
+ message: why
61
+ ? `Its sources (${files}) need API sources allowed for ${place}, and ${why}, so Apply would refuse it`
62
+ : `Its sources (${files}) need API sources allowed for ${place}. This project has no Knowledge settings to check that against, so Apply refuses it if the workspace keeps API sources off, as a new workspace does`,
63
+ fix: `set "sources": {"apiSources": "${base.scope === 'organization' ? 'organization' : 'all_workspaces'}"} in ${SETTINGS} and propose it with the base (an organization owner or admin applies it), or leave the source out`,
64
+ })
65
+ }
66
+ return needs
67
+ }
@@ -28,7 +28,7 @@ import { projectTargets, targetOf, targetProfileName } from './local-sync-link'
28
28
  export const PERSON_STEPS_FILE = join('.frontera', 'sync', 'person-steps.json')
29
29
 
30
30
  /** Who or what the step needs. */
31
- export const CREDENTIALS = ['workspace-key', 'organization-key', 'steward', 'admin', 'second-person', 'person', 'registry-owner'] as const
31
+ export const CREDENTIALS = ['workspace-key', 'organization-key', 'steward', 'knowledge-steward', 'admin', 'second-person', 'person', 'registry-owner'] as const
32
32
  export type Credential = (typeof CREDENTIALS)[number]
33
33
 
34
34
  /** Each credential in the person's words, as the card and the list say it. */
@@ -36,6 +36,7 @@ export const CREDENTIAL_WORDS: Record<Credential, string> = {
36
36
  'workspace-key': 'the project’s workspace key',
37
37
  'organization-key': 'an organization key',
38
38
  steward: 'a Blueprint Steward',
39
+ 'knowledge-steward': 'a Knowledge steward',
39
40
  admin: 'a workspace admin',
40
41
  'second-person': 'a second person (not whoever made the change)',
41
42
  person: 'you, signed in (`frontera auth login --browser`)',
@@ -5,6 +5,7 @@ import { parse as parseYaml } from 'yaml'
5
5
 
6
6
  import { CliError, UsageError } from '../errors'
7
7
  import { isIgnoredName } from './local-dev-sync'
8
+ import { KNOWLEDGE_BASE_DIR } from './local-knowledge-base-files'
8
9
  import { localRuntimeAt } from './local-runtime-target'
9
10
  import type { LocalCredentials } from './local-state'
10
11
 
@@ -20,7 +21,8 @@ import type { LocalCredentials } from './local-state'
20
21
  * The submitter is an `owner` (an Action grants its invoke capability to
21
22
  * owners only); approvers are `admin`s. `frontera action available|submit|approve|reject --as
22
23
  * <persona>` then runs as that persona, and so do `media list|upload|stage` (both roles hold
23
- * Media content write, so a persona can stage the file an attachment Action takes). The session it uses is minted for the
24
+ * Media content write, so a persona can stage the file an attachment Action takes). A project with
25
+ * knowledge bases also gets the `KNOWLEDGE_PERSONAS`, which `frontera knowledge-base` reads as. The session it uses is minted for the
24
26
  * one command, sent only to the local runtime, and never printed or logged.
25
27
  */
26
28
 
@@ -32,8 +34,22 @@ export const ACT_AS_COMMANDS = [
32
34
  'action available', 'action submit', 'action approve', 'action reject',
33
35
  // L6: a persona's files, into the runtime's stand-in Media Sets, and staged for an Action to attach.
34
36
  'media list', 'media upload', 'media stage',
37
+ // Who reads which knowledge base: the steward, a member of the workspace and someone outside it.
38
+ 'knowledge-base list', 'knowledge-base status', 'knowledge-base search', 'knowledge-base documents',
35
39
  ] as const
36
40
 
41
+ /**
42
+ * The people a project with knowledge bases tests access with: a Knowledge
43
+ * steward (manages organization bases), a member of the workspace (reads its
44
+ * bases and those shared with it) and an outsider, a member of the
45
+ * organization in no workspace here, who reads none of them.
46
+ */
47
+ export const KNOWLEDGE_PERSONAS: readonly PersonaSpec[] = [
48
+ { key: 'knowledge-steward', displayName: 'Knowledge steward', orgRole: 'editor', knowledgeSteward: true, decides: ['organization knowledge bases, as a Knowledge steward'] },
49
+ { key: 'workspace-member', displayName: 'Workspace member', orgRole: 'viewer', decides: ['reading this workspace\'s knowledge bases'] },
50
+ { key: 'outsider', displayName: 'Outsider', orgRole: 'viewer', inWorkspace: false, decides: ['nothing: in the organization, in no workspace here'] },
51
+ ]
52
+
37
53
  const KEY = /^[a-z][a-z0-9_-]{0,62}$/
38
54
  /** The most personas one seed takes (the route's limit). */
39
55
  const MAX_PERSONAS = 20
@@ -51,7 +67,11 @@ export interface PersonaSpec {
51
67
  * The organization role it holds: `owner` for the submitter (an Action
52
68
  * grants its invoke capability to owners only), `admin` for an approver.
53
69
  */
54
- orgRole: 'owner' | 'admin'
70
+ orgRole: 'owner' | 'admin' | 'editor' | 'viewer'
71
+ /** `false`: a member of the organization only, in no workspace here. */
72
+ inWorkspace?: false
73
+ /** Made a Knowledge steward after it is seeded, with the owner's session. */
74
+ knowledgeSteward?: true
55
75
  /** What the persona is for, in words: "decides step dispatch_lead of escalateWorkOrder". */
56
76
  decides: string[]
57
77
  }
@@ -128,8 +148,9 @@ export function personaPlan(root: string): PersonaPlan {
128
148
  const names = actionDocs(root).map(({ file, doc }) => (typeof doc.apiName === 'string' ? doc.apiName : file))
129
149
  add(SUBMITTER, names.length ? `requests for ${names.join(', ')}` : 'requests, as the person who submits them')
130
150
  }
131
- // The submitter first, then the approvers as their steps come.
151
+ // The submitter first, then the approvers as their steps come, then the knowledge personas.
132
152
  const personas = [...byKey.values()].sort((a, b) => Number(b.key === SUBMITTER) - Number(a.key === SUBMITTER))
153
+ if (existsSync(join(root, KNOWLEDGE_BASE_DIR))) personas.push(...KNOWLEDGE_PERSONAS.filter((p) => !byKey.has(p.key)).map((p) => ({ ...p, decides: [...p.decides] })))
133
154
  if (personas.length > MAX_PERSONAS) notes.push(`${personas.length - MAX_PERSONAS} more approval steps than the ${MAX_PERSONAS} personas a runtime seeds at once: the rest were left out`)
134
155
  return { personas: personas.slice(0, MAX_PERSONAS), notes }
135
156
  }
@@ -199,15 +220,48 @@ export async function seedPersonas(root: string, serviceUrl: string, creds: Loca
199
220
  if (!plan.personas.length) return lines
200
221
  let answer: { status: number; body: unknown }
201
222
  try {
202
- answer = await personaCall(serviceUrl, creds, fetcher)('POST', '/', { personas: plan.personas.map((p) => ({ key: p.key, displayName: p.displayName, orgRole: p.orgRole })) })
223
+ answer = await personaCall(serviceUrl, creds, fetcher)('POST', '/', { personas: plan.personas.map((p) => ({ key: p.key, displayName: p.displayName, orgRole: p.orgRole, ...(p.inWorkspace === false ? { inWorkspace: false } : {}) })) })
203
224
  } catch (err) {
204
225
  return [...lines, ` note: local personas could not be seeded: ${(err as Error).message}`]
205
226
  }
206
227
  if (answer.status === 404) return [...lines, ' note: this runtime has no local personas (its platform predates them), so approval chains can only be decided by a person here']
207
228
  if (answer.status >= 300) return [...lines, ` note: local personas could not be seeded: ${messageOf(answer)}`]
208
229
  const keys = plan.personas.map((p) => p.key)
209
- const approver = keys.find((k) => k !== SUBMITTER)
210
- return [...lines, ` local personas ${keys.join(', ')}: act as one with \`--as\`${approver ? ` (\`frontera action approve <requestId> --as ${approver}\`)` : ''}`]
230
+ const approver = keys.find((k) => k !== SUBMITTER && !KNOWLEDGE_PERSONAS.some((p) => p.key === k))
231
+ const stewards = plan.personas.filter((p) => p.knowledgeSteward).map((p) => p.key)
232
+ const stewardLines = stewards.length ? await assignStewards(serviceUrl, creds, stewards, answer.body, fetcher) : []
233
+ const knowledge = keys.includes('outsider') ? ' (`frontera knowledge-base search "<words>" --as outsider`)' : ''
234
+ return [...lines, ...stewardLines, ` local personas ${keys.join(', ')}: act as one with \`--as\`${approver ? ` (\`frontera action approve <requestId> --as ${approver}\`)` : knowledge}`]
235
+ }
236
+
237
+ /**
238
+ * Make the seeded personas `keys` Knowledge stewards, with the owner's
239
+ * session: the Knowledge app takes no organization key. Notes only, never a
240
+ * failure; the route assigns a steward again without change.
241
+ */
242
+ async function assignStewards(serviceUrl: string, creds: LocalCredentials, keys: string[], seeded: unknown, fetcher: Fetch): Promise<string[]> {
243
+ const data = inner(seeded)
244
+ const rows = (isRecord(data) && Array.isArray(data.personas) ? data.personas : []).filter(isRecord)
245
+ const notes: string[] = []
246
+ for (const key of keys) {
247
+ const userId = rows.find((p) => p.key === key)?.userId
248
+ if (!creds.session || typeof userId !== 'string') {
249
+ notes.push(` note: ${key} could not be made a Knowledge steward: ${creds.session ? 'the runtime did not name it' : 'the runtime has no owner session; restart it'}`)
250
+ continue
251
+ }
252
+ try {
253
+ const res = await fetcher(new URL('/v1/knowledge-app/stewards', serviceUrl), {
254
+ method: 'POST',
255
+ headers: { authorization: `Bearer ${creds.session}`, 'x-org-id': creds.organizationId, 'content-type': 'application/json' },
256
+ body: JSON.stringify({ userId }),
257
+ })
258
+ if (res.status === 404) notes.push(` note: ${key} is not a Knowledge steward: this runtime does not run the Knowledge app (\`frontera local start --with search-index\`)`)
259
+ else if (res.status >= 300) notes.push(` note: ${key} could not be made a Knowledge steward: ${messageOf({ status: res.status, body: await res.json().catch(() => null) })}`)
260
+ } catch (err) {
261
+ notes.push(` note: ${key} could not be made a Knowledge steward: ${(err as Error).message}`)
262
+ }
263
+ }
264
+ return notes
211
265
  }
212
266
 
213
267
  export interface PersonaSession {
@@ -251,7 +305,7 @@ export async function actAsLocalPersona(opts: {
251
305
  throw new CliError(known ? `no local persona is named ${key}` : 'this runtime has no local personas', {
252
306
  code: 'PERSONA_NOT_FOUND',
253
307
  hint: known
254
- ? `${known.length ? `the personas here are ${known.map((p) => p.key).join(', ')}; ` : ''}\`frontera local dev\` seeds one per approval step in blueprint/actions/, plus ${SUBMITTER}`
308
+ ? `${known.length ? `the personas here are ${known.map((p) => p.key).join(', ')}; ` : ''}\`frontera local dev\` seeds one per approval step in blueprint/actions/, plus ${SUBMITTER}, and ${KNOWLEDGE_PERSONAS.map((p) => p.key).join(', ')} for a project with knowledge-base/`
255
309
  : 'its platform predates them: start the runtime from a release that has them (`frontera local start`)',
256
310
  })
257
311
  }
@@ -298,6 +352,9 @@ export function localPersonaHint(meta: { noun: string; verb: string }, personas:
298
352
  const approver = approvers[0] ?? 'approver'
299
353
  const listed = personas?.length ? `the personas here are ${personas.map((p) => p.key).join(', ')}` : '`frontera local status` lists the personas (Personas)'
300
354
  const never = 'never a person’s session token'
355
+ if (meta.noun === 'knowledge-base' && (ACT_AS_COMMANDS as readonly string[]).includes(label)) {
356
+ return `on this local runtime, act as a local persona: ${KNOWLEDGE_PERSONAS.map((p) => `\`--as ${p.key}\``).join(', ')} (\`frontera ${label} --as outsider\`); ${listed}: ${never}`
357
+ }
301
358
  if (!(ACT_AS_COMMANDS as readonly string[]).includes(label)) {
302
359
  return `on this local runtime no person signs in, and \`--as <persona>\` acts as a local persona only on ${ACT_AS_COMMANDS.map((c) => `\`${c}\``).join(', ')}; \`${label}\` runs against a deployment, where a person signs in (${never} here)`
303
360
  }
@@ -3,6 +3,7 @@ import { posix } from 'node:path'
3
3
  import { parse as parseYaml } from 'yaml'
4
4
 
5
5
  import { actionSemanticConflicts, type SemanticFacts } from '../blueprint/action-semantic-fit'
6
+ import { KNOWLEDGE_BASE_FILE, KNOWLEDGE_BASE_SOURCES_DIR, type KnowledgeBaseFile, type KnowledgeBaseSourceFile } from './local-knowledge-base-files'
6
7
  import { unnamedObjectBindings } from './local-objects'
7
8
  import { governedActionIds, referencesIn } from './local-sync-refs'
8
9
 
@@ -101,6 +102,10 @@ export type Need =
101
102
  * index from before this read the same Action as `action-refused`.)
102
103
  */
103
104
  | { kind: 'action-inherited'; reasons: string[] }
105
+ /** A knowledge base's source that signs in (`credentialSecret`): here with the secret set on the runtime, on the platform with a credential entered on the base's page. */
106
+ | { kind: 'source-credential'; source: string; secret: string }
107
+ /** An organization knowledge base shared with chosen workspaces: here, with the runtime's one workspace. */
108
+ | { kind: 'shared-workspaces'; workspaces: string[] }
104
109
 
105
110
  export interface Portability {
106
111
  class: PortabilityClass
@@ -172,6 +177,10 @@ export function judge(need: Need, ctx: Context = {}): { class: PortabilityClass;
172
177
  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` }
173
178
  case 'context-unread':
174
179
  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' }
180
+ case 'source-credential':
181
+ return { class: 'adapted', text: `its source ${need.source} signs in here with the secret ${need.secret} set on this runtime; on the platform, its credential is entered on the knowledge base’s page` }
182
+ case 'shared-workspaces':
183
+ return { class: 'adapted', text: `shared with ${listed(need.workspaces)} on the platform; here, with this runtime’s one workspace, the project’s` }
175
184
  }
176
185
  }
177
186
 
@@ -415,7 +424,25 @@ function parse(path: string, text: string): unknown {
415
424
  /** What a built-in Plugin is, when it is not a connection to another system. */
416
425
  export type BuiltInKind = 'documentation' | 'analytics'
417
426
 
427
+ /** What a knowledge base's files need: its sharing with chosen workspaces, and each source that signs in. */
428
+ export function knowledgeBaseNeeds(base: KnowledgeBaseFile, sources: ReadonlyArray<{ stem: string; spec: { credentialSecret?: string } }>): Need[] {
429
+ const needs: Need[] = []
430
+ if (base.scope === 'organization' && base.sharing && 'workspaces' in base.sharing) needs.push({ kind: 'shared-workspaces', workspaces: base.sharing.workspaces })
431
+ for (const s of sources) if (s.spec.credentialSecret) needs.push({ kind: 'source-credential', source: s.stem, secret: s.spec.credentialSecret })
432
+ return needs
433
+ }
434
+
418
435
  export function itemNeeds(kind: string, files: Record<string, string>, connections: ReadonlySet<string> = new Set(), builtIns: ReadonlyMap<string, BuiltInKind> = new Map()): Need[] {
436
+ if (kind === 'knowledge-base') {
437
+ const base = Object.entries(files).find(([path]) => path.endsWith(`/${KNOWLEDGE_BASE_FILE}`))
438
+ const doc = base ? (parse(base[0], base[1]) as KnowledgeBaseFile | null) : null
439
+ const sources = Object.entries(files).flatMap(([path, text]) => {
440
+ if (!new RegExp(`/${KNOWLEDGE_BASE_SOURCES_DIR}/[^/]+\\.json$`).test(path)) return []
441
+ const spec = parse(path, text) as KnowledgeBaseSourceFile | null
442
+ return spec ? [{ stem: posix.basename(path, '.json'), spec }] : []
443
+ })
444
+ return doc && typeof doc === 'object' ? knowledgeBaseNeeds(doc, sources) : []
445
+ }
419
446
  const needs: Need[] = []
420
447
  const actions = new Set<string>()
421
448
  const plugins = new Set<string>()
@@ -20,14 +20,15 @@ import { removeRuntimeResources } from './local-docker'
20
20
  * one, for every project whose folder is gone.
21
21
  */
22
22
 
23
- export type PortName = 'postgres' | 'service' | 'jobs' | 'runner' | 'studio' | 'cache' | 'embeddings' | 'storage' | 'traces'
23
+ export type PortName = 'postgres' | 'service' | 'jobs' | 'runner' | 'studio' | 'cache' | 'embeddings' | 'storage' | 'traces' | 'search-index'
24
24
 
25
25
  export type PortBlock = Record<PortName, number>
26
26
 
27
27
  const BASE = 54100
28
28
  const BLOCK = 10
29
29
  const MAX_BLOCKS = 40
30
- const OFFSETS: Record<PortName, number> = { studio: 0, service: 1, jobs: 2, runner: 3, postgres: 4, cache: 5, embeddings: 6, storage: 7, traces: 8 }
30
+ // The block is full: another port would change BLOCK, and with it every project's ports.
31
+ const OFFSETS: Record<PortName, number> = { studio: 0, service: 1, jobs: 2, runner: 3, postgres: 4, cache: 5, embeddings: 6, storage: 7, traces: 8, 'search-index': 9 }
31
32
 
32
33
  interface Registry {
33
34
  version: 1
@@ -59,6 +60,7 @@ function blockFrom(start: number): PortBlock {
59
60
  embeddings: start + OFFSETS.embeddings,
60
61
  storage: start + OFFSETS.storage,
61
62
  traces: start + OFFSETS.traces,
63
+ 'search-index': start + OFFSETS['search-index'],
62
64
  }
63
65
  }
64
66
 
@@ -1,9 +1,10 @@
1
- import { existsSync, readFileSync } from 'node:fs'
2
- import { join } from 'node:path'
1
+ import { existsSync, readdirSync, readFileSync } from 'node:fs'
2
+ import { dirname, join } from 'node:path'
3
3
 
4
4
  import { parse as parseYaml } from 'yaml'
5
5
 
6
6
  import { allTargets } from './local-dev-sync'
7
+ import { KNOWLEDGE_BASE_SOURCES_DIR } from './local-knowledge-base-files'
7
8
  import { functionSecretNames, hasSecretValue, type SecretPresence, workflowSecretNames } from './local-secrets'
8
9
 
9
10
  /**
@@ -67,14 +68,38 @@ export function readProjectSecrets(root: string): ProjectSecrets | null {
67
68
  return { secrets, problems }
68
69
  }
69
70
 
71
+ /** The secret each source of a Knowledge app base signs in with (`credentialSecret`), with its file. */
72
+ function sourceSecrets(root: string, baseFile: string): Array<[string, string]> {
73
+ const dir = join(dirname(baseFile), KNOWLEDGE_BASE_SOURCES_DIR)
74
+ let names: string[] = []
75
+ try {
76
+ names = readdirSync(join(root, dir)).filter((n) => n.endsWith('.json')).sort()
77
+ } catch {
78
+ return []
79
+ }
80
+ return names.flatMap((n): Array<[string, string]> => {
81
+ try {
82
+ const secret = (JSON.parse(readFileSync(join(root, dir, n), 'utf8')) as { credentialSecret?: unknown }).credentialSecret
83
+ return typeof secret === 'string' && secret ? [[secret, join(dir, n)]] : []
84
+ } catch {
85
+ return []
86
+ }
87
+ })
88
+ }
89
+
70
90
  /**
71
- * The secret names the project's Functions and workflows name in their files,
72
- * each with the first file that names it: a Function's `secret:NAME` grants
73
- * (a single-file Function, or a folder's entry) and a workflow's `auth`.
91
+ * The secret names the project's Functions, workflows and knowledge base
92
+ * sources name in their files, each with the first file that names it: a
93
+ * Function's `secret:NAME` grants (a single-file Function, or a folder's
94
+ * entry), a workflow's `auth` and a source's `credentialSecret`.
74
95
  */
75
96
  export function secretsNamedInFiles(root: string): Map<string, string> {
76
97
  const out = new Map<string, string>()
77
98
  for (const target of allTargets(root)) {
99
+ if (target.kind === 'knowledge-base') {
100
+ for (const [name, file] of sourceSecrets(root, target.file)) if (!out.has(name)) out.set(name, file)
101
+ continue
102
+ }
78
103
  if (target.kind !== 'function' && target.kind !== 'workflow') continue
79
104
  let names: string[] = []
80
105
  try {
@@ -582,6 +582,8 @@ const GITIGNORE = `node_modules/
582
582
  .env
583
583
  # A separate browser plugin's output (screenshots of other sites included); the frontera browser keeps its own under .frontera/.
584
584
  .playwright-mcp/
585
+ # Knowledge base documents: brought on request, often the customer's own content.
586
+ knowledge-base/*/documents/
585
587
  `
586
588
 
587
589
  /**
@@ -7,7 +7,7 @@
7
7
  * Only the keywords this CLI's own schemas use (`local-schemas.ts`,
8
8
  * `local-blueprint-schemas.ts`): type, enum, const, required, properties,
9
9
  * additionalProperties, items, the length and count bounds, pattern,
10
- * minimum/maximum, anyOf, oneOf and allOf. A keyword it does not know is not
10
+ * minimum/maximum, anyOf, oneOf, allOf and not. A keyword it does not know is not
11
11
  * checked, so a schema is never stricter here than it says.
12
12
  */
13
13
 
@@ -118,6 +118,8 @@ export function checkAgainstSchema(value: unknown, schema: Schema, path = ''): S
118
118
  }
119
119
  }
120
120
 
121
+ // `{ not: {} }` matches nothing: on a property, the key must be absent.
122
+ if (isRecord(schema.not) && checkAgainstSchema(value, schema.not, path).length === 0) say(Object.keys(schema.not).length ? 'is not allowed here' : 'must be left out')
121
123
  for (const sub of Array.isArray(schema.allOf) ? schema.allOf : []) if (isRecord(sub)) out.push(...checkAgainstSchema(value, sub, path))
122
124
  if (Array.isArray(schema.anyOf) && schema.anyOf.length) {
123
125
  const results = schema.anyOf.map((b) => (isRecord(b) ? checkAgainstSchema(value, b, path) : []))
@@ -7,6 +7,7 @@ import type { LocalProject } from './local-project'
7
7
  import { isAlive, readState } from './local-state'
8
8
  import { PROCEDURE_TOOL_FORMS, PROCEDURE_TOOL_GROUPS } from './local-agent-checks'
9
9
  import { DEFAULT_HANDOVER_REASON_KEYS } from './local-operating'
10
+ import { KNOWLEDGE_BASE_SCHEMA, KNOWLEDGE_BASE_SETTINGS_SCHEMA, KNOWLEDGE_BASES_FIELD } from './local-knowledge-base-files'
10
11
  import { TARGET_NAME } from './local-sync-link'
11
12
  import { REF_KINDS, refPattern, type RefKind } from './local-sync-refs'
12
13
 
@@ -14,7 +15,7 @@ import { REF_KINDS, refPattern, type RefKind } from './local-sync-refs'
14
15
  * The formats of a project's files, as JSON Schema, for the coding agents and
15
16
  * editors working in it (`frontera local schema <kind>`).
16
17
  *
17
- * The service checks four of them (an Agent, its behavior, a workflow, an MCP server), so their schemas come from the service
18
+ * The service checks five of them (an Agent, its behavior, a workflow, an MCP server, a knowledge base source), so their schemas come from the service
18
19
  * the runtime runs (`GET /v1/config/file-schemas/<kind>`), made from the same
19
20
  * zod schemas that check the files, then widened where a project file names a
20
21
  * resource by reference instead of id (`localizeServiceSchema`). The others
@@ -38,6 +39,9 @@ export const SCHEMA_KINDS = [
38
39
  'app',
39
40
  'mcp-server',
40
41
  'knowledge',
42
+ 'knowledge-base',
43
+ 'knowledge-base-source',
44
+ 'knowledge-base-settings',
41
45
  'skill',
42
46
  'blueprint-object-type',
43
47
  'blueprint-link-type',
@@ -66,6 +70,9 @@ export const SCHEMA_FILES: Record<SchemaKind, string> = {
66
70
  app: 'apps/<slug>.json',
67
71
  'mcp-server': 'mcp-servers/<slug>.yaml',
68
72
  knowledge: 'knowledge/<name>/knowledge.json',
73
+ 'knowledge-base': 'knowledge-base/<name>/knowledge-base.json',
74
+ 'knowledge-base-source': 'knowledge-base/<name>/sources/<source>.json',
75
+ 'knowledge-base-settings': 'knowledge-base/settings.json',
69
76
  skill: 'skills/<name>/SKILL.md (its front matter)',
70
77
  secrets: 'secrets.json',
71
78
  sync: 'frontera.yaml (its sync block)',
@@ -826,6 +833,8 @@ export function cliSchema(kind: SchemaKind): JsonSchema | null {
826
833
  if (kind === 'chat-app') return CHAT_APP_SCHEMA
827
834
  if (kind === 'app') return APP_SCHEMA
828
835
  if (kind === 'knowledge') return KNOWLEDGE_SCHEMA
836
+ if (kind === 'knowledge-base') return KNOWLEDGE_BASE_SCHEMA
837
+ if (kind === 'knowledge-base-settings') return KNOWLEDGE_BASE_SETTINGS_SCHEMA
829
838
  if (kind === 'skill') return SKILL_SCHEMA
830
839
  if (kind === 'secrets') return SECRETS_SCHEMA
831
840
  if (kind === 'sync') return SYNC_SCHEMA
@@ -967,6 +976,7 @@ function describeProjectFields(props: Record<string, unknown>): void {
967
976
  required: ['knowledgeConfigId'],
968
977
  },
969
978
  })
979
+ if (!('knowledgeBases' in props)) props.knowledgeBases = KNOWLEDGE_BASES_FIELD
970
980
  describe(props, 'objectBindings', 'Object definitions (the workspace’s objects, not Blueprint object types) the Agent reads and writes through its object tools: each row `objectDefinitionId: object/<slug>` with its `definition` (name, fields). The Blueprint object types it may query are granted in agents/<slug>/bindings.json `blueprint`, not here.')
971
981
  describe(props, 'functions', 'Not applied: these rows are no longer run. The Functions and workflows the Agent may start are its bindings, in agents/<slug>/bindings.json (`frontera local schema bindings`).')
972
982
  // `config.features`: the Agent's own Computer availability, kept beside the on/off switches (the service's `computer-runtime-policy.ts`).
@@ -0,0 +1,92 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+
4
+ import { searchIndexVolumeName, type DockerRunSpec } from './local-docker'
5
+
6
+ /**
7
+ * The local runtime's search index: one OpenSearch node per project, for the
8
+ * Knowledge app's knowledge bases. It runs only for a project that builds
9
+ * knowledge bases (`knowledge-base/`) or when asked (`--with search-index`),
10
+ * because it adds about 1 GB of memory to the runtime.
11
+ */
12
+
13
+ /** The engine the platform's compose file and chart run. */
14
+ export const SEARCH_INDEX_IMAGE = 'opensearchproject/opensearch:3.8.0'
15
+
16
+ /**
17
+ * Docker memory below which it stays off. Its own cap is 1.5 GiB: the JVM uses
18
+ * about 850 MiB beside a 512 MB heap when idle. With the runtime's other
19
+ * containers that comes to about 5.3 GB. Docker Desktop reports about 97% of
20
+ * its Memory limit (8092 MiB read as 7851 MiB), so a 6 GB limit reads about
21
+ * 5.8 GiB: the check takes 5.5 GiB, and the message names the limit to set.
22
+ */
23
+ const MIN_DOCKER_MEMORY = 5.5 * 1024 ** 3
24
+
25
+ export type SearchIndexChoice = { on: true } | { on: false; why: string }
26
+
27
+ export function searchIndexChoice(input: { root: string; with: readonly string[]; exclude: readonly string[]; dockerMemoryBytes: number | null }): SearchIndexChoice {
28
+ if (input.exclude.includes('search-index')) return { on: false, why: 'excluded' }
29
+ if (!input.with.includes('search-index') && !existsSync(join(input.root, 'knowledge-base'))) {
30
+ return { on: false, why: 'no knowledge-base/ folder in the project; `frontera local start --with search-index` starts it' }
31
+ }
32
+ if (input.dockerMemoryBytes !== null && input.dockerMemoryBytes < MIN_DOCKER_MEMORY) {
33
+ const has = (input.dockerMemoryBytes / 1024 ** 3).toFixed(1)
34
+ return {
35
+ on: false,
36
+ why: `Docker has ${has} GB of memory, and the search index needs more: in Docker Desktop, set Settings > Resources > Memory limit to 6 GB or more, then run \`frontera local start\` again`,
37
+ }
38
+ }
39
+ return { on: true }
40
+ }
41
+
42
+ /** Uploaded files go to object storage and are read by the jobs engine's workers. */
43
+ export function searchIndexNeeds(exclude: readonly string[]): string | null {
44
+ const missing = exclude.filter((name) => name === 'storage' || name === 'jobs')
45
+ if (!missing.length) return null
46
+ return `the search index needs storage and jobs: drop \`--exclude ${missing.join(',')}\`, or add \`--exclude search-index\``
47
+ }
48
+
49
+ export function searchIndexRunSpec(args: { name: string; projectId: string; port: number }): DockerRunSpec {
50
+ return {
51
+ name: args.name,
52
+ image: SEARCH_INDEX_IMAGE,
53
+ projectId: args.projectId,
54
+ // Only the REST port: the performance analyzer's 9600 stays inside the container.
55
+ ports: [{ host: args.port, container: 9200 }],
56
+ env: {
57
+ 'discovery.type': 'single-node',
58
+ // It listens on loopback only, as the platform's compose file runs it for development.
59
+ DISABLE_SECURITY_PLUGIN: 'true',
60
+ DISABLE_INSTALL_DEMO_CONFIG: 'true',
61
+ OPENSEARCH_JAVA_OPTS: '-Xms512m -Xmx512m',
62
+ },
63
+ volumes: [{ name: searchIndexVolumeName(args.projectId), path: '/usr/share/opensearch/data' }],
64
+ memory: '1536m',
65
+ cpus: '1',
66
+ flags: ['--ulimit', 'nofile=65536:65536'],
67
+ }
68
+ }
69
+
70
+ /** Null when the search index answers with a usable cluster (a single node is yellow), else why not. */
71
+ export async function searchIndexHealth(port: number, timeoutMs = 1500): Promise<string | null> {
72
+ let res: Response
73
+ try {
74
+ res = await fetch(`http://127.0.0.1:${port}/_cluster/health`, { signal: AbortSignal.timeout(timeoutMs) })
75
+ } catch {
76
+ return `nothing answers on its port ${port}`
77
+ }
78
+ if (!res.ok) return `its cluster health answered ${res.status}`
79
+ const status = ((await res.json().catch(() => null)) as { status?: string } | null)?.status
80
+ return status === 'green' || status === 'yellow' ? null : `its cluster health is ${status ?? 'unreadable'}`
81
+ }
82
+
83
+ /** Waits for a usable cluster; the last reason when it never came. */
84
+ export async function waitForSearchIndex(port: number, timeoutMs: number): Promise<string | null> {
85
+ const deadline = Date.now() + timeoutMs
86
+ let why = await searchIndexHealth(port)
87
+ while (why && Date.now() < deadline) {
88
+ await Bun.sleep(1000)
89
+ why = await searchIndexHealth(port)
90
+ }
91
+ return why
92
+ }
@@ -129,6 +129,8 @@ export async function missingSecretLines(names: string[], who: string, serviceUr
129
129
  export interface SecretPresence {
130
130
  names: Set<string>
131
131
  withoutValue: Set<string>
132
+ /** When each listed secret was last set, as the route says; what a source's credential is sent again after. */
133
+ updatedAt?: Map<string, string>
132
134
  }
133
135
 
134
136
  /** Whether the runtime has a value for `name`: listed, and not said to be without one. */
@@ -145,16 +147,19 @@ export async function localSecretPresence(serviceUrl: string, creds: LocalCreden
145
147
  signal: AbortSignal.timeout(5000),
146
148
  })
147
149
  if (!res.ok) return null
148
- const body = (await res.json()) as { data?: Array<{ name?: string; hasValue?: unknown }> } | Array<{ name?: string; hasValue?: unknown }>
150
+ type Row = { name?: string; hasValue?: unknown; updatedAt?: unknown }
151
+ const body = (await res.json()) as { data?: Row[] } | Row[]
149
152
  const rows = Array.isArray(body) ? body : (body.data ?? [])
150
153
  const names = new Set<string>()
151
154
  const withoutValue = new Set<string>()
155
+ const updatedAt = new Map<string, string>()
152
156
  for (const r of rows) {
153
157
  if (typeof r.name !== 'string') continue
154
158
  names.add(r.name)
155
159
  if (r.hasValue === false) withoutValue.add(r.name)
160
+ if (typeof r.updatedAt === 'string') updatedAt.set(r.name, r.updatedAt)
156
161
  }
157
- return { names, withoutValue }
162
+ return { names, withoutValue, updatedAt }
158
163
  } catch {
159
164
  return null
160
165
  }
@@ -380,6 +380,23 @@ export async function ensureProviderModels(args: { serviceUrl: string; password:
380
380
  return added
381
381
  }
382
382
 
383
+ /**
384
+ * With the search index on, the seeded owner is a Knowledge steward, so
385
+ * organization knowledge bases can be created and shared here. Assigning one
386
+ * again does nothing, so every start asks; a refusal is logged, never fatal.
387
+ */
388
+ export async function ensureKnowledgeSteward(args: { serviceUrl: string; password: string; organizationId: string; logFile: string }): Promise<void> {
389
+ const signedIn = await call<{ token?: string; user?: { id?: string } }>(args.serviceUrl, '/api/auth/sign-in/email', { body: { email: OWNER_EMAIL, password: args.password } })
390
+ const token = signedIn.body?.token ?? signedIn.headers.get('set-auth-token')
391
+ const userId = signedIn.body?.user?.id
392
+ if (signedIn.status !== 200 || !token || !userId) {
393
+ log(args.logFile, `could not sign in to make the owner a Knowledge steward (${signedIn.status})`)
394
+ return
395
+ }
396
+ const assigned = await call(args.serviceUrl, '/v1/knowledge-app/stewards', { token, orgId: args.organizationId, body: { userId } })
397
+ if (assigned.status >= 300) log(args.logFile, `making the owner a Knowledge steward answered ${assigned.status}: ${JSON.stringify(assigned.body).slice(0, 200)}`)
398
+ }
399
+
383
400
  /**
384
401
  * Make Blueprint usable in a fresh local organization: the lifecycle
385
402
  * adopted, so the Blueprint CLI loop (`pull`, `plan`, `apply`) and Studio's Blueprint section
@@ -3,6 +3,7 @@ import { join } from 'node:path'
3
3
 
4
4
  import { ignoreInFronteraDir } from '../project-context'
5
5
  import { writeFileAtomic } from '../fs-atomic'
6
+ import type { PortBlock } from './local-ports'
6
7
  import type { LocalProject } from './local-project'
7
8
 
8
9
  /**
@@ -14,7 +15,10 @@ import type { LocalProject } from './local-project'
14
15
  * same file, and the supervisor's pid in it says whether anyone is home.
15
16
  */
16
17
 
17
- export type ComponentName = 'postgres' | 'storage' | 'service' | 'jobs' | 'runner' | 'studio'
18
+ /** Every component, in the order the supervisor starts them and `local status` lists them. */
19
+ export const COMPONENTS = ['postgres', 'storage', 'search-index', 'service', 'jobs', 'runner', 'studio'] as const
20
+
21
+ export type ComponentName = (typeof COMPONENTS)[number]
18
22
 
19
23
  export type ComponentStatus = 'off' | 'starting' | 'running' | 'stopped' | 'failed'
20
24
 
@@ -63,7 +67,7 @@ export interface LocalState {
63
67
  error?: string
64
68
  startedAt: string
65
69
  updatedAt: string
66
- ports: Record<'postgres' | 'service' | 'jobs' | 'runner' | 'studio' | 'cache' | 'embeddings' | 'storage' | 'traces', number>
70
+ ports: PortBlock
67
71
  components: ComponentState[]
68
72
  credentials?: LocalCredentials
69
73
  }
@@ -113,10 +117,8 @@ export function updateComponent(state: LocalState, next: ComponentState): LocalS
113
117
  return { ...state, components: [...others, next].sort(byOrder) }
114
118
  }
115
119
 
116
- const ORDER: ComponentName[] = ['postgres', 'storage', 'service', 'jobs', 'runner', 'studio']
117
-
118
120
  function byOrder(a: ComponentState, b: ComponentState): number {
119
- return ORDER.indexOf(a.name) - ORDER.indexOf(b.name)
121
+ return COMPONENTS.indexOf(a.name) - COMPONENTS.indexOf(b.name)
120
122
  }
121
123
 
122
124
  /** Whether a pid names a live process. Signal 0 checks without delivering anything. */