@frontera-sdk/cli 1.53.23 → 1.53.25
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 +4 -4
- package/src/api/knowledge-base-api.ts +230 -0
- package/src/commands/knowledge/index-commands.ts +6 -6
- package/src/commands/knowledge-base/index-commands.ts +328 -0
- package/src/commands/local/check.ts +12 -0
- package/src/commands/local/dev.ts +64 -2
- package/src/commands/local/index-commands.ts +34 -10
- package/src/commands/local/init.ts +11 -2
- package/src/commands/local/schema.ts +1 -1
- package/src/commands/local/sync-request.ts +135 -29
- package/src/commands/local/sync.ts +40 -25
- package/src/commands/registry.ts +4 -1
- package/src/commands/types.ts +6 -0
- package/src/flag-help.ts +10 -4
- package/src/forge/touches.ts +29 -0
- package/src/local/local-applied.ts +28 -0
- package/src/local/local-build-summary.ts +1 -0
- package/src/local/local-component-health.ts +7 -2
- package/src/local/local-dev-references.ts +6 -2
- package/src/local/local-dev-sync.ts +43 -4
- package/src/local/local-docker.ts +16 -1
- package/src/local/local-env.ts +19 -0
- package/src/local/local-file-checks.ts +56 -8
- package/src/local/local-knowledge-base-apply.ts +714 -0
- package/src/local/local-knowledge-base-files.ts +195 -0
- package/src/local/local-knowledge-base-pull.ts +458 -0
- package/src/local/local-knowledge-base-sources-off.ts +67 -0
- package/src/local/local-person-steps.ts +2 -1
- package/src/local/local-personas.ts +64 -7
- package/src/local/local-portability.ts +27 -0
- package/src/local/local-ports.ts +4 -2
- package/src/local/local-project-secrets.ts +30 -5
- package/src/local/local-scaffold.ts +2 -0
- package/src/local/local-schema-check.ts +3 -1
- package/src/local/local-schemas.ts +11 -1
- package/src/local/local-search-index.ts +92 -0
- package/src/local/local-secrets.ts +7 -2
- package/src/local/local-seed.ts +17 -0
- package/src/local/local-state.ts +7 -5
- package/src/local/local-studio-server.ts +3 -3
- package/src/local/local-supervisor.ts +45 -8
- package/src/local/local-sync-link.ts +16 -0
- package/src/local/local-sync-names.ts +10 -1
- package/src/local/local-sync-plan.ts +44 -5
- package/src/local/local-sync-push.ts +165 -34
- package/src/local/local-sync-refs.ts +3 -1
- package/src/local/local-sync-remote.ts +44 -12
- package/src/local/local-sync-snapshot.ts +156 -12
- package/src/main.ts +13 -0
- package/src/mcp/mcp-server.ts +3 -0
- package/src/mcp/tools/docs-tools.ts +1 -1
- 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).
|
|
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
|
-
|
|
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>()
|
package/src/local/local-ports.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
72
|
-
* each with the first file that names it: a
|
|
73
|
-
* (a single-file Function, or a folder's
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
}
|
package/src/local/local-seed.ts
CHANGED
|
@@ -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
|
package/src/local/local-state.ts
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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
|
|
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. */
|