@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.
- 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
|
@@ -76,6 +76,9 @@ import { applyApp } from '../../local/local-apps'
|
|
|
76
76
|
import { applyBrand, brandUpload } from '../../local/local-brand'
|
|
77
77
|
import { applyLibrary } from '../../local/local-library'
|
|
78
78
|
import { projectFileChecks } from '../../local/local-file-checks'
|
|
79
|
+
import { applyAgentKnowledgeBases, applyKnowledgeBase, applyKnowledgeSettings, knowledgeBaseCalls, knowledgeBasesOff, knowledgeOffOutput, movedCredentials, removeDeletedBases } from '../../local/local-knowledge-base-apply'
|
|
80
|
+
import { readKeptSchema } from '../../local/local-schemas'
|
|
81
|
+
import { readProviderSetup } from '../../local/local-providers'
|
|
79
82
|
import { actionFailureLines, dedupeLines, hasIndexedPaths, nameIndexedPaths, refusedArtifact, refusedByRules, sourceSystemHint } from '../../local/local-blueprint-errors'
|
|
80
83
|
|
|
81
84
|
/**
|
|
@@ -192,7 +195,7 @@ async function apply(project: LocalProject, target: SyncTarget, opts: ApplyOptio
|
|
|
192
195
|
// What the Agent's files say that would apply in silence and fail in a chat (`local-agent-checks.ts`).
|
|
193
196
|
const agent = agentChecks(project, target)
|
|
194
197
|
// A Chat App, knowledge base or skill against its format: a key the platform drops is said, a wrong shape refused.
|
|
195
|
-
const formats = projectFileChecks(project.root, target as { kind: string; file?: string })
|
|
198
|
+
const formats = projectFileChecks(project.root, target as { kind: string; file?: string }, (kind) => readKeptSchema(project, kind))
|
|
196
199
|
const checked = { errors: [...agent.errors, ...formats.errors], warnings: [...agent.warnings, ...formats.warnings] }
|
|
197
200
|
if (checked.errors.length) return { ok: false, output: [`error [USAGE]: ${describeTarget(target)} can't be applied as written`, ...checked.errors.map((e) => ` ${e}`), ...checked.warnings].join('\n') }
|
|
198
201
|
const resolved = await resolveTarget(project, target, lookup, creds?.workspaceId, creds?.organizationId)
|
|
@@ -217,6 +220,15 @@ async function apply(project: LocalProject, target: SyncTarget, opts: ApplyOptio
|
|
|
217
220
|
if (!creds) return { ok: false, output: 'error [LOCAL_NOT_SEEDED]: the runtime is not seeded yet' }
|
|
218
221
|
return withNotes(await applyBindings(project, target, opts.serviceUrl, creds))
|
|
219
222
|
}
|
|
223
|
+
if (target.kind === 'knowledge-base' || target.kind === 'knowledge-base-settings') {
|
|
224
|
+
if (!creds?.session) return { ok: false, output: 'error [LOCAL_NOT_SEEDED]: the runtime is not seeded yet' }
|
|
225
|
+
// As the runtime's owner, a Knowledge steward: an organization base is made at the organization level.
|
|
226
|
+
const call = knowledgeBaseCalls(opts.serviceUrl, creds)
|
|
227
|
+
const off = await knowledgeBasesOff(readState(project)?.components, call)
|
|
228
|
+
if (off) return { ok: false, output: knowledgeOffOutput(off) }
|
|
229
|
+
if (target.kind === 'knowledge-base-settings') return withNotes(await applyKnowledgeSettings(project.root, { call, workspaceId: creds.workspaceId, preferredModel: readProviderSetup().defaults.agentModel ?? null }))
|
|
230
|
+
return withNotes(await applyKnowledgeBase(project, target.name, { call, organizationId: creds.organizationId, workspaceId: creds.workspaceId, secrets: await localSecretPresence(opts.serviceUrl, creds) }))
|
|
231
|
+
}
|
|
220
232
|
if (target.kind === 'knowledge') return withNotes(await applyKnowledge(project, target, profiles, (args) => runCli(project, args, opts.auth), creds ? { serviceUrl: opts.serviceUrl, creds } : undefined))
|
|
221
233
|
if (target.kind === 'theme') {
|
|
222
234
|
if (!creds?.session) return { ok: false, output: 'error [LOCAL_NOT_SEEDED]: the runtime is not seeded yet' }
|
|
@@ -431,6 +443,14 @@ async function apply(project: LocalProject, target: SyncTarget, opts: ApplyOptio
|
|
|
431
443
|
// Agents stay drafts here, so the row keeps the name it was created with unless set from the file.
|
|
432
444
|
const named = await syncAgentName({ slug: target.slug, identity: agentIdentity(project.root, target.file), serviceUrl: opts.serviceUrl, creds })
|
|
433
445
|
if (named) result = { ...result, output: `${result.output}\n${named}`.trim() }
|
|
446
|
+
// Its Knowledge app bases (`knowledgeBases`): set live, outside the draft, once the Agent is here.
|
|
447
|
+
if (creds.session) {
|
|
448
|
+
const call = knowledgeBaseCalls(opts.serviceUrl, creds)
|
|
449
|
+
const found = await call('workspace', 'GET', `/v1/config/agents/${encodeURIComponent(target.slug)}`).catch(() => ({ status: 0, body: null }))
|
|
450
|
+
const agentId = found.status === 200 ? ((found.body as { data?: { id?: string } } | null)?.data?.id ?? null) : null
|
|
451
|
+
const picks = await applyAgentKnowledgeBases(project, original.file, agentId, call)
|
|
452
|
+
if (picks.length) result = { ...result, output: [result.output, ...picks].join('\n').trim() }
|
|
453
|
+
}
|
|
434
454
|
}
|
|
435
455
|
let authoredAgain: string[] = []
|
|
436
456
|
if (actionCalls && blueprint) {
|
|
@@ -928,7 +948,14 @@ async function dev(ctx: CommandContext) {
|
|
|
928
948
|
const embeddings = target.kind === 'knowledge' ? ((await embeddingsDiffer(project.root, { serviceUrl, creds }).catch((): false => false)) || ('same' as const)) : undefined
|
|
929
949
|
const embeddingsMoved = !!embeddings && !!hash && applied.get(target.key) === hash && (recorded?.embeddings ?? 'same') !== embeddings
|
|
930
950
|
if (embeddingsMoved) ctx.output.note(` ${describeTarget(target)}: the runtime’s embeddings changed since it was applied (${embeddingWords(recorded?.embeddings ?? 'same')} then, ${embeddingWords(embeddings!)} now): applying it again so its threshold follows`)
|
|
931
|
-
|
|
951
|
+
// A Knowledge app base, or the Knowledge settings, while the runtime's search index is off: unchanged files,
|
|
952
|
+
// but nothing here can search the base, so it is applied, and fails saying why and what to run.
|
|
953
|
+
const knowledgeOff = (target.kind === 'knowledge-base' || target.kind === 'knowledge-base-settings') && !!hash && applied.get(target.key) === hash && !!creds.session && !!(await knowledgeBasesOff(readState(project)?.components, knowledgeBaseCalls(serviceUrl, creds)))
|
|
954
|
+
// A base whose source signs in with a secret set, or taken away, on the runtime since it was applied (`frontera secret set`):
|
|
955
|
+
// unchanged files, but it is applied again so the source signs in with the value there now.
|
|
956
|
+
const secretsMoved = target.kind === 'knowledge-base' && !!hash && applied.get(target.key) === hash && !knowledgeOff ? await movedCredentials(project, target.name, organizationId, () => localSecretPresence(serviceUrl, creds)) : []
|
|
957
|
+
if (secretsMoved.length) ctx.output.note(` ${describeTarget(target)}: ${secretsMoved.join(', ')} changed on the runtime since it was applied: applying it again so its sources sign in with the value there now`)
|
|
958
|
+
if (hash && applied.get(target.key) === hash && !draftGone && !actionsDown.length && !versionUnknown && !embeddingsMoved && !knowledgeOff && !secretsMoved.length) {
|
|
932
959
|
unchanged += 1
|
|
933
960
|
// An Agent applied before names were set from its file still reads its slug: set it once.
|
|
934
961
|
if (target.kind === 'agent' && !named.has(target.slug)) {
|
|
@@ -1069,6 +1096,8 @@ async function dev(ctx: CommandContext) {
|
|
|
1069
1096
|
let secretsDue = false
|
|
1070
1097
|
/** Whether to deploy again the Custom Apps a database restore took away (`local-app-restore.ts`): at the start, and after a restore. */
|
|
1071
1098
|
let appsDue = true
|
|
1099
|
+
/** Whether to look for knowledge-base/ folders deleted since their base was made (`removeDeletedBases`): at the start, and after one goes. */
|
|
1100
|
+
let basesDue = true
|
|
1072
1101
|
|
|
1073
1102
|
/**
|
|
1074
1103
|
* A Custom App deployed after the baseline a restore went back to is not on
|
|
@@ -1183,6 +1212,13 @@ async function dev(ctx: CommandContext) {
|
|
|
1183
1212
|
appsDue = false
|
|
1184
1213
|
await redeployRestoredApps()
|
|
1185
1214
|
}
|
|
1215
|
+
// After this round's applies, so a renamed folder has found its base by its name first. A base folder
|
|
1216
|
+
// here that fails to apply may be one renamed: the pass waits for it, still due.
|
|
1217
|
+
const baseFailing = [...failing.keys()].some((key) => key.startsWith('knowledge-base:') && existsSync(join(project.root, 'knowledge-base', key.slice('knowledge-base:'.length))))
|
|
1218
|
+
if (basesDue && !stopping && !runtimeDown && !baseFailing) {
|
|
1219
|
+
basesDue = false
|
|
1220
|
+
await removeDeletedKnowledgeBases()
|
|
1221
|
+
}
|
|
1186
1222
|
} finally {
|
|
1187
1223
|
busy = false
|
|
1188
1224
|
if (round.since) {
|
|
@@ -1363,6 +1399,27 @@ async function dev(ctx: CommandContext) {
|
|
|
1363
1399
|
}
|
|
1364
1400
|
}
|
|
1365
1401
|
|
|
1402
|
+
/** The knowledge bases this project made here whose folder was deleted: removed, said, and forgotten as applied, so a folder put back is applied again. */
|
|
1403
|
+
const removeDeletedKnowledgeBases = async (): Promise<void> => {
|
|
1404
|
+
if (!creds?.session) return
|
|
1405
|
+
try {
|
|
1406
|
+
const { lines, removed, kept } = await removeDeletedBases(project, organizationId, knowledgeBaseCalls(serviceUrl, creds), readState(project)?.components)
|
|
1407
|
+
for (const line of lines) ctx.output.note(line)
|
|
1408
|
+
// A base kept for a folder that has not applied yet: looked at again after the next round.
|
|
1409
|
+
if (kept.length) basesDue = true
|
|
1410
|
+
if (!removed.length) return
|
|
1411
|
+
const known = loadHashes(project, organizationId)
|
|
1412
|
+
for (const key of removed.map((folder) => `knowledge-base:${folder}`)) {
|
|
1413
|
+
applied.delete(key)
|
|
1414
|
+
known.delete(key)
|
|
1415
|
+
}
|
|
1416
|
+
saveHashes(project, organizationId, known)
|
|
1417
|
+
recordApplied(project, { at: new Date().toISOString(), what: removed.map((folder) => `knowledge-base/${folder}`).join(', '), ok: true })
|
|
1418
|
+
} catch (err) {
|
|
1419
|
+
ctx.output.note(` warning: the knowledge bases of deleted knowledge-base/ folders couldn’t be checked: ${(err as Error).message}`)
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1366
1423
|
// Watch from the start, so a save during the first sync is not missed.
|
|
1367
1424
|
const once = flagBool(ctx, 'once')
|
|
1368
1425
|
// One watcher per project (Forge keeps one running; a second would apply every change twice). `--once` is always allowed.
|
|
@@ -1390,6 +1447,11 @@ async function dev(ctx: CommandContext) {
|
|
|
1390
1447
|
// An Agent's folder, a workflow's or a Function's file deleted: what the runtime holds follows.
|
|
1391
1448
|
if (/^(agents|workflows|functions)([\\/]|$)/.test(filename.toString()) && !existsSync(join(project.root, filename.toString()))) void sweepOrphans()
|
|
1392
1449
|
if (/^agents[\\/][^\\/]+[\\/]operating\.json$/.test(filename.toString()) && !existsSync(join(project.root, filename.toString()))) void clearDeletedBehavior()
|
|
1450
|
+
// Something under knowledge-base/ deleted: a base folder gone goes from the runtime after the next round.
|
|
1451
|
+
if (/^knowledge-base[\\/]/.test(filename.toString()) && !existsSync(join(project.root, filename.toString()))) {
|
|
1452
|
+
basesDue = true
|
|
1453
|
+
if (!busy) schedule()
|
|
1454
|
+
}
|
|
1393
1455
|
// Only a change that queued an item starts a round: the watcher's own writes under `.frontera/`,
|
|
1394
1456
|
// a screenshot, a coding agent's settings or a note in docs/ never do.
|
|
1395
1457
|
if (queuePath(filename.toString()) && !busy) schedule()
|
|
@@ -15,7 +15,7 @@ import { findObjectSet, normalizeObjectSets, projectObjectSetNames } from '../..
|
|
|
15
15
|
import { actionsStatusText, appsStatusText, buildStatusText, emptyKnowledgeLines, itemKindsText, notPulledLines, objectSetsStatusText, projectBuildRows, projectBuildSummary, projectFilesLines } from '../../local/local-build-summary'
|
|
16
16
|
import { inheritedActionWarnings } from '../../local/local-check-inherited'
|
|
17
17
|
import { checkoutVersion, docsStatus, versionAtStart } from '../../local/local-docs'
|
|
18
|
-
import { dockerAvailable } from '../../local/local-docker'
|
|
18
|
+
import { dockerAvailable, dockerMemoryBytes } from '../../local/local-docker'
|
|
19
19
|
import { downPhase, downToRestart, liveComponents, type DownComponent } from '../../local/local-component-health'
|
|
20
20
|
import { readConfig, writeConfig, type LocalConfig } from '../../local/local-env'
|
|
21
21
|
import { configuredProviders } from '../../local/local-keys'
|
|
@@ -31,7 +31,8 @@ import {
|
|
|
31
31
|
stopRuntime,
|
|
32
32
|
waitForPhase,
|
|
33
33
|
} from '../../local/local-control'
|
|
34
|
-
import { clearState, ensureStateDir, isAlive, logPath, readState, type ComponentName, type LocalState } from '../../local/local-state'
|
|
34
|
+
import { clearState, COMPONENTS, ensureStateDir, isAlive, logPath, readState, type ComponentName, type LocalState } from '../../local/local-state'
|
|
35
|
+
import { searchIndexChoice, searchIndexNeeds } from '../../local/local-search-index'
|
|
35
36
|
import { newSessionToken } from '../../local/local-studio-server'
|
|
36
37
|
import { migrateMcpConfigs } from '../../local/local-scaffold'
|
|
37
38
|
import { DEFAULT_REGISTRY, runSupervisor } from '../../local/local-supervisor'
|
|
@@ -79,8 +80,9 @@ import { readLock, recentWriters, writerWords } from '../../local/local-writers'
|
|
|
79
80
|
* Frontera Studio on top of it. Nothing here talks to a Frontera deployment.
|
|
80
81
|
*/
|
|
81
82
|
|
|
82
|
-
const
|
|
83
|
-
|
|
83
|
+
const OPTIONAL: ComponentName[] = ['jobs', 'runner', 'storage', 'search-index']
|
|
84
|
+
/** Off unless the project needs it or `--with` names it. */
|
|
85
|
+
const ON_REQUEST: ComponentName[] = ['search-index']
|
|
84
86
|
|
|
85
87
|
const sessionFile = (project: LocalProject) => join(project.stateDir, 'studio-session')
|
|
86
88
|
|
|
@@ -126,10 +128,10 @@ function renderStatus(state: LocalState, project: LocalProject, profile?: { prof
|
|
|
126
128
|
for (const c of state.components) {
|
|
127
129
|
const where = c.kind === 'container' ? 'container' : c.kind === 'process' ? 'process' : 'supervisor'
|
|
128
130
|
const status = down.some((d) => d.name === c.name) ? 'down' : c.status
|
|
129
|
-
lines.push(` ${DOT[status] ?? '·'} ${c.name.padEnd(
|
|
131
|
+
lines.push(` ${DOT[status] ?? '·'} ${c.name.padEnd(12)} ${status.padEnd(9)} ${c.status === 'off' ? (c.detail ?? '') : where}${c.status === 'failed' && c.detail ? ` — ${c.detail}` : ''}`)
|
|
130
132
|
}
|
|
131
133
|
const cache = down.find((d) => d.name === 'cache')
|
|
132
|
-
if (cache) lines.push(` ✕ ${'cache'.padEnd(
|
|
134
|
+
if (cache) lines.push(` ✕ ${'cache'.padEnd(12)} ${'down'.padEnd(9)} container — ${cache.why}; \`frontera local start\` brings it back`)
|
|
133
135
|
if (state.credentials) {
|
|
134
136
|
lines.push('')
|
|
135
137
|
lines.push(` Signed in as ${state.credentials.email}`)
|
|
@@ -222,6 +224,17 @@ function parseList(raw: string | undefined): ComponentName[] {
|
|
|
222
224
|
return names
|
|
223
225
|
}
|
|
224
226
|
|
|
227
|
+
function parseWith(raw: string | undefined): ComponentName[] {
|
|
228
|
+
if (!raw) return []
|
|
229
|
+
const names = raw.split(',').map((s) => s.trim()).filter(Boolean) as ComponentName[]
|
|
230
|
+
for (const n of names) {
|
|
231
|
+
if (!ON_REQUEST.includes(n)) {
|
|
232
|
+
throw new UsageError(`\`${n}\` cannot be added with --with`, `only ${ON_REQUEST.join(', ')} starts on request; the other components run unless excluded`)
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
return names
|
|
236
|
+
}
|
|
237
|
+
|
|
225
238
|
async function start(ctx: CommandContext): Promise<{ data: unknown; text: string }> {
|
|
226
239
|
const project = localProject(ctx.cwd)
|
|
227
240
|
// Coding-agent configs from before `frontera mcp` launched the documentation server alone.
|
|
@@ -262,13 +275,21 @@ async function start(ctx: CommandContext): Promise<{ data: unknown; text: string
|
|
|
262
275
|
if (sourceFlag && !isPlatformSource(source!)) {
|
|
263
276
|
throw new UsageError(`${source} is not a Frontera platform checkout`, 'point --source at the repository root that holds packages/service')
|
|
264
277
|
}
|
|
265
|
-
const exclude = keep && !flagString(ctx, 'exclude') ? previous!.exclude : parseList(flagString(ctx, 'exclude'))
|
|
278
|
+
const exclude: string[] = keep && !flagString(ctx, 'exclude') ? previous!.exclude : parseList(flagString(ctx, 'exclude'))
|
|
279
|
+
const added: string[] = keep && !flagString(ctx, 'with') ? (previous!.with ?? []) : parseWith(flagString(ctx, 'with'))
|
|
280
|
+
const both = added.find((n) => exclude.includes(n))
|
|
281
|
+
if (both) throw new UsageError(`\`${both}\` is both added with --with and excluded`, `pass one of \`--with ${both}\` and \`--exclude ${both}\``)
|
|
282
|
+
// The supervisor decides the same way when it starts it.
|
|
283
|
+
const searchIndex = searchIndexChoice({ root: project.root, with: added, exclude, dockerMemoryBytes: dockerMemoryBytes() })
|
|
284
|
+
const needs = searchIndex.on ? searchIndexNeeds(exclude) : null
|
|
285
|
+
if (needs) throw new UsageError(needs, 'the search index runs for a project with a knowledge-base/ folder, or with `--with search-index`')
|
|
266
286
|
const config: LocalConfig = {
|
|
267
287
|
mode: source ? 'source' : 'image',
|
|
268
288
|
platformSource: source ?? undefined,
|
|
269
289
|
platformVersion: versionAtStart({ checkout: source ? checkoutVersion(source) : null, flag: flagString(ctx, 'version'), previous, cli: cliVersion() }),
|
|
270
290
|
registry: normalizeRegistry(flagString(ctx, 'registry') ?? process.env.FRONTERA_LOCAL_REGISTRY ?? previous?.registry),
|
|
271
291
|
exclude,
|
|
292
|
+
...(added.length ? { with: added } : {}),
|
|
272
293
|
}
|
|
273
294
|
ensureStateDir(project)
|
|
274
295
|
writeConfig(project, config)
|
|
@@ -277,7 +298,7 @@ async function start(ctx: CommandContext): Promise<{ data: unknown; text: string
|
|
|
277
298
|
const freed = reclaimedNote(reclaimed)
|
|
278
299
|
if (freed) ctx.output.note(freed)
|
|
279
300
|
for (const name of [...COMPONENTS, 'cache', 'embeddings', 'traces'] as const) {
|
|
280
|
-
if (exclude.includes(name
|
|
301
|
+
if (exclude.includes(name) || (name === 'search-index' && !searchIndex.on)) continue
|
|
281
302
|
if (!(await isPortFree(ports[name]))) {
|
|
282
303
|
const holder = portHolder(ports[name])
|
|
283
304
|
throw new CliError(`port ${ports[name]} (${name}) is already in use${holder ? ` by ${holder}` : ''}`, {
|
|
@@ -322,9 +343,12 @@ async function start(ctx: CommandContext): Promise<{ data: unknown; text: string
|
|
|
322
343
|
|
|
323
344
|
const startMeta = {
|
|
324
345
|
args: [],
|
|
325
|
-
flags: { source: 'string', image: 'boolean', version: 'string', registry: 'string', exclude: 'string' },
|
|
346
|
+
flags: { source: 'string', image: 'boolean', version: 'string', registry: 'string', exclude: 'string', with: 'string' },
|
|
326
347
|
summary: 'Start Frontera\'s runtimes on this machine and seed an owner, workspace and keys',
|
|
327
|
-
|
|
348
|
+
details: [
|
|
349
|
+
'The search index (for knowledge bases) runs when the project has a knowledge-base/ folder, or with `--with search-index`; `--exclude search-index` keeps it off. It needs storage and jobs, and Docker with about 6 GB of memory (Docker Desktop: Settings > Resources > Memory limit): with less it stays off and `frontera local status` says so.',
|
|
350
|
+
],
|
|
351
|
+
examples: ['frontera local start', 'frontera local start --exclude runner', 'frontera local start --with search-index', 'frontera local start --image --version 1.52.19', 'frontera local start --image --registry registry.example.com/frontera'],
|
|
328
352
|
offline: true,
|
|
329
353
|
} as const
|
|
330
354
|
|
|
@@ -27,6 +27,13 @@ import { localSync } from './sync'
|
|
|
27
27
|
* kinds, and sample data for the pulled Blueprint. `frontera local start`
|
|
28
28
|
* then runs it, and `local dev` applies what was pulled.
|
|
29
29
|
*/
|
|
30
|
+
/**
|
|
31
|
+
* Everything a solution is made of: Apps, Chat Apps and slide themes too, so a
|
|
32
|
+
* pulled project has what the workspace lists. Knowledge bases come without
|
|
33
|
+
* their documents, and a platform that does not take them pulls none.
|
|
34
|
+
*/
|
|
35
|
+
const FROM_WORKSPACE_KINDS = 'blueprint,skills,functions,plugins,knowledge,knowledge-bases,workflows,agents,evaluations,themes,chat-apps,apps'
|
|
36
|
+
|
|
30
37
|
export const localInit: Command = {
|
|
31
38
|
meta: {
|
|
32
39
|
noun: 'local',
|
|
@@ -46,6 +53,9 @@ export const localInit: Command = {
|
|
|
46
53
|
rows: 'string',
|
|
47
54
|
seed: 'string',
|
|
48
55
|
},
|
|
56
|
+
flagHelp: {
|
|
57
|
+
kinds: `with --from-workspace, the kinds to pull, separated by commas (default: ${FROM_WORKSPACE_KINDS.split(',').join(', ')}); knowledge bases come without their documents (\`frontera local sync pull --documents knowledge-base/<name>\` brings them); \`none\` links the project and pulls nothing`,
|
|
58
|
+
},
|
|
49
59
|
summary: 'Create a project: an Agent with a test case, a workflow, a Function and a Blueprint object type with its data',
|
|
50
60
|
examples: [
|
|
51
61
|
'frontera local init claims-intake',
|
|
@@ -158,8 +168,7 @@ async function startFromWorkspace(ctx: CommandContext, target: string): Promise<
|
|
|
158
168
|
} else if (!readLink(target)) {
|
|
159
169
|
throw new UsageError('no workspace to start from', 'pass --platform <url> --workspace-key - with the key on stdin')
|
|
160
170
|
}
|
|
161
|
-
|
|
162
|
-
const kinds = flagString(ctx, 'kinds') ?? 'blueprint,skills,functions,plugins,knowledge,workflows,agents,evaluations,themes,chat-apps,apps'
|
|
171
|
+
const kinds = flagString(ctx, 'kinds') ?? FROM_WORKSPACE_KINDS
|
|
163
172
|
// `none` links the project and pulls nothing, for someone who picks items next (`local sync pull <kind/name>…`).
|
|
164
173
|
if (kinds === 'none') {
|
|
165
174
|
const link = readLink(target)
|
|
@@ -16,7 +16,7 @@ export const localSchema: Command = {
|
|
|
16
16
|
verb: 'schema',
|
|
17
17
|
args: [{ name: 'kind', required: false, description: SCHEMA_KINDS.join(' | ') }],
|
|
18
18
|
flags: {},
|
|
19
|
-
summary: 'Print the JSON Schema of a project file (Agent, its bindings, workflow, tests, dataset, slide theme, Blueprint) for the runtime\'s release',
|
|
19
|
+
summary: 'Print the JSON Schema of a project file (Agent, its bindings, workflow, tests, dataset, slide theme, Blueprint, knowledge base) for the runtime\'s release',
|
|
20
20
|
examples: ['frontera local schema', 'frontera local schema operating', 'frontera local schema workflow-tests', 'frontera local schema blueprint-action', 'frontera local schema bindings', "frontera local schema workflow | jq '.\"$defs\" | keys'"],
|
|
21
21
|
offline: true,
|
|
22
22
|
},
|
|
@@ -13,7 +13,11 @@ import { mcpServersNotCarried } from '../../local/local-mcp-servers'
|
|
|
13
13
|
import { localProject, type LocalProject } from '../../local/local-project'
|
|
14
14
|
import { HISTORY_KINDS, projectAtRevision } from '../../local/local-sync-base'
|
|
15
15
|
import { normalizeOrigin, unwrap } from '../../local/local-sync-client'
|
|
16
|
-
import { getSyncKey, readLink, registerTargetProfile, setModelMapping, targetLabel, targetOf, targetProfileName, targetSession, targetWords, type SyncLink, type SyncTarget } from '../../local/local-sync-link'
|
|
16
|
+
import { getSyncKey, personReader, readLink, registerTargetProfile, setModelMapping, targetLabel, targetOf, targetProfileName, targetSession, targetWords, type SyncLink, type SyncTarget } from '../../local/local-sync-link'
|
|
17
|
+
import { KNOWLEDGE_BASE_DIR } from '../../local/local-knowledge-base-files'
|
|
18
|
+
import { KNOWLEDGE_SETTINGS_REF, knowledgeBaseDocumentsOn } from '../../local/local-knowledge-base-pull'
|
|
19
|
+
import { sourcesOffNeeds } from '../../local/local-knowledge-base-sources-off'
|
|
20
|
+
import type { PersonClients } from '../../local/local-sync-remote'
|
|
17
21
|
import { itemTitle } from '../../local/local-sync-names'
|
|
18
22
|
import { checkBody, markMissingModels, markRefused, movedOnTarget, needsAPerson, planOf, type ApplyStep, type WorkspaceModel } from '../../local/local-sync-precheck'
|
|
19
23
|
import { afterApplyOf, recordAfterApply } from '../../local/local-person-steps'
|
|
@@ -86,6 +90,9 @@ function needLink(project: LocalProject): SyncLink {
|
|
|
86
90
|
return link
|
|
87
91
|
}
|
|
88
92
|
|
|
93
|
+
/** An item as a line names it: its kind and title; the Knowledge settings by their title alone. */
|
|
94
|
+
const labelOf = (i: { ref: string; kind: SyncKind; title: string }) => (i.ref === KNOWLEDGE_SETTINGS_REF ? i.title : `${KIND_WORD[i.kind]} ${i.title}`)
|
|
95
|
+
|
|
89
96
|
const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? '' : 's'}`
|
|
90
97
|
|
|
91
98
|
// ── The platform ────────────────────────────────────────────────────────────
|
|
@@ -331,6 +338,8 @@ interface ViewItem {
|
|
|
331
338
|
id: string
|
|
332
339
|
ref: string
|
|
333
340
|
kind: SyncKind
|
|
341
|
+
/** The platform's id of what the item became, once applied (a knowledge base Apply created). */
|
|
342
|
+
resourceId?: string | null
|
|
334
343
|
/** What the item's candidate is: evidence taken for it is bound to it. */
|
|
335
344
|
candidateDigest?: string
|
|
336
345
|
name: string
|
|
@@ -547,12 +556,13 @@ function assetUploader(project: Pick<LocalProject, 'root'>, api: Platform) {
|
|
|
547
556
|
async function stageDocuments(project: Pick<LocalProject, 'root'>, api: Platform, view: View): Promise<number> {
|
|
548
557
|
let sent = 0
|
|
549
558
|
for (const item of view.items) {
|
|
550
|
-
if (item.kind !== 'knowledge' || !item.documents) continue
|
|
559
|
+
if ((item.kind !== 'knowledge' && item.kind !== 'knowledge-base') || !item.documents) continue
|
|
551
560
|
for (const doc of item.documents.upload) {
|
|
552
561
|
if (doc.staged) continue
|
|
553
|
-
|
|
554
|
-
const
|
|
555
|
-
|
|
562
|
+
// A Knowledge app base keeps its documents in `documents/`, with no folders.
|
|
563
|
+
const path = item.kind === 'knowledge' ? `knowledge/${item.name}/${documentPath(doc.folder, doc.fileName)}` : `${KNOWLEDGE_BASE_DIR}/${item.name}/documents/${doc.fileName}`
|
|
564
|
+
const bytes = new Uint8Array(readFileSync(join(project.root, path)))
|
|
565
|
+
if (sha256Of(bytes) !== doc.sha256) throw new CliError(`${path} changed while it was proposed`, { code: 'CONFLICT', hint: 'push again' })
|
|
556
566
|
await api.send('PUT', `/v1/change-requests/${view.request.id}/documents/${doc.sha256}`, bytes, 'application/octet-stream', { timeoutMs: 300_000 })
|
|
557
567
|
sent++
|
|
558
568
|
}
|
|
@@ -578,6 +588,29 @@ async function platformDocuments(api: Platform, names: string[], remote: Array<{
|
|
|
578
588
|
return out
|
|
579
589
|
}
|
|
580
590
|
|
|
591
|
+
/** What the workspace holds of each Knowledge app base whose documents are brought, read as the person signed in: its documents by name. */
|
|
592
|
+
async function baseDocumentsOn(person: PersonClients | null, refs: string[], remote: Array<{ ref: string; id?: string }>): Promise<Map<string, Array<{ id: string; fileName: string }>>> {
|
|
593
|
+
const out = new Map<string, Array<{ id: string; fileName: string }>>()
|
|
594
|
+
for (const ref of refs) {
|
|
595
|
+
const id = remote.find((r) => r.ref === ref)?.id
|
|
596
|
+
out.set(ref, person && id ? await knowledgeBaseDocumentsOn(person.workspace, id) : [])
|
|
597
|
+
}
|
|
598
|
+
return out
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/** The words for what a knowledge base step needs of the person applying (the platform's `knowledge_*` needs), as this CLI's next step. */
|
|
602
|
+
function knowledgeNextStep(code: string, acting: Acting): string | null {
|
|
603
|
+
if (code === 'knowledge_needs_person') return `sign in with \`${signInCommand(acting.profile)}\` and push again, so the change request is yours to apply as yourself`
|
|
604
|
+
if (code === 'knowledge_needs_steward') return 'apply it signed in as a Knowledge steward, or ask one to apply it; an organization owner or admin names stewards (`frontera knowledge-base steward add`)'
|
|
605
|
+
if (code === 'knowledge_settings_need_admin') return 'ask an organization owner or admin to apply it, or take the parts only they set (searchModel, sources) out of knowledge-base/settings.json'
|
|
606
|
+
return null
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/** What Apply needs of a person, each with this CLI's next step where it has its own. */
|
|
610
|
+
function personNeeds(plan: ApplyStep[], acting: Acting): ReturnType<typeof needsAPerson> {
|
|
611
|
+
return needsAPerson(plan).map((n) => ({ ...n, fix: knowledgeNextStep(n.code, acting) ?? n.fix }))
|
|
612
|
+
}
|
|
613
|
+
|
|
581
614
|
/**
|
|
582
615
|
* What still uses each item this push would delete, read with the platform's
|
|
583
616
|
* own in-use check before anything is proposed, so a deletion names its
|
|
@@ -637,7 +670,7 @@ function changedThereLines(target: SyncTarget, changed: Array<{ ref: string; kin
|
|
|
637
670
|
return [
|
|
638
671
|
'',
|
|
639
672
|
`Changed in ${targetLabel(target)} since this project last delivered it, so not proposed:`,
|
|
640
|
-
...changed.map((i) => ` ${
|
|
673
|
+
...changed.map((i) => ` ${labelOf(i)}: to put the project’s version back over it, \`${targetWords(target, `frontera local sync resolve ${i.ref} --keep mine`)}\``),
|
|
641
674
|
]
|
|
642
675
|
}
|
|
643
676
|
|
|
@@ -653,13 +686,10 @@ function previewText(target: SyncTarget, items: PushItem[], kept: { title: strin
|
|
|
653
686
|
// Each item's notes under it.
|
|
654
687
|
...ok.flatMap((i) => [` [x] ${pushItemLine(i)}`, ...(usedBy.has(i.ref) ? [` ! Still used by ${usedBy.get(i.ref)!.join(', ')}: applying would be refused`] : []), ...i.notes.map((n) => ` · ${n}`)]),
|
|
655
688
|
]
|
|
656
|
-
if (samples.length) lines.push('', 'Samples from setting up the project, left out unless named:', ...samples.map((i) => ` [ ] ${
|
|
657
|
-
if (not.length) lines.push('', 'Not proposed:', ...not.flatMap((i) => [` [ ] ${
|
|
689
|
+
if (samples.length) lines.push('', 'Samples from setting up the project, left out unless named:', ...samples.map((i) => ` [ ] ${labelOf(i)}`))
|
|
690
|
+
if (not.length) lines.push('', 'Not proposed:', ...not.flatMap((i) => [` [ ] ${labelOf(i)}: ${i.reason}`, ...(i.fix ? [` Fix: ${i.fix}`] : [])]))
|
|
658
691
|
lines.push(...changedThereLines(target, changedThere))
|
|
659
|
-
|
|
660
|
-
const after = person.filter((p) => p.after)
|
|
661
|
-
if (before.length) lines.push('', 'Needs a person before Apply:', ...before.flatMap((p) => [` - ${p.step}: ${p.message}`, ` Fix: ${p.fix}`]))
|
|
662
|
-
if (after.length) lines.push('', 'Left for a person after Apply:', ...after.flatMap((p) => [` - ${p.step}: ${p.message}`, ` Then: ${p.fix}`]))
|
|
692
|
+
lines.push(...needLines(person))
|
|
663
693
|
if (orgNote) lines.push('', orgNote)
|
|
664
694
|
if (notCarried) lines.push('', notCarried)
|
|
665
695
|
if (!available) lines.push('', 'This platform does not take change requests yet.')
|
|
@@ -667,6 +697,16 @@ function previewText(target: SyncTarget, items: PushItem[], kept: { title: strin
|
|
|
667
697
|
return lines.join('\n')
|
|
668
698
|
}
|
|
669
699
|
|
|
700
|
+
/** What Apply needs of a person, before it and after it, as the preview and the push say it. */
|
|
701
|
+
function needLines(person: ReturnType<typeof needsAPerson>): string[] {
|
|
702
|
+
const before = person.filter((p) => !p.after)
|
|
703
|
+
const after = person.filter((p) => p.after)
|
|
704
|
+
return [
|
|
705
|
+
...(before.length ? ['', 'Needs a person before Apply:', ...before.flatMap((p) => [` - ${p.step}: ${p.message}`, ` Fix: ${p.fix}`])] : []),
|
|
706
|
+
...(after.length ? ['', 'Left for a person after Apply:', ...after.flatMap((p) => [` - ${p.step}: ${p.message}`, ` Then: ${p.fix}`])] : []),
|
|
707
|
+
]
|
|
708
|
+
}
|
|
709
|
+
|
|
670
710
|
/**
|
|
671
711
|
* The release items asked for: each App resolved in the workspace, and the
|
|
672
712
|
* deployment named, or its newest one (what was deployed last, previews
|
|
@@ -729,20 +769,39 @@ export async function syncPush(ctx: CommandContext): Promise<{ data: unknown; te
|
|
|
729
769
|
const command = (words: string) => targetWords(target, `frontera local sync ${words}`)
|
|
730
770
|
// Fetch first, as git does before a push: an item the workspace moved past is pulled and merged first.
|
|
731
771
|
// A named target is never pulled from: it is compared with what this project last delivered to it (`targetPlan`).
|
|
732
|
-
|
|
772
|
+
// A project with knowledge bases reads the whole index: the names Apply finds a base by can move without its revision.
|
|
773
|
+
const { plan, lock, snapshot, remote, delivered } = target.name ? await targetPlan(project, target) : { ...(await planFor(project, link, { fetch: 'always', repair: 'online', full: existsSync(join(project.root, KNOWLEDGE_BASE_DIR)) })), delivered: null }
|
|
733
774
|
if (!plan || !snapshot) throw new CliError('could not fetch the workspace', { code: 'SYNC_NOT_FETCHED', hint: 'run `frontera local sync fetch` and look at what it says' })
|
|
734
775
|
const available = snapshot.capabilities.includes('change-requests')
|
|
735
776
|
// Knowledge bases whose documents come too: opt-in, as for a pull (`*` for every one the project has).
|
|
736
|
-
|
|
737
|
-
const
|
|
738
|
-
const
|
|
739
|
-
const
|
|
740
|
-
|
|
777
|
+
// Agent knowledge by name (`knowledge/<name>`), a Knowledge app base by its folder (`knowledge-base/<folder>`).
|
|
778
|
+
const asked = (flagString(ctx, 'documents') ?? '').split(',').map((s) => s.trim()).filter(Boolean)
|
|
779
|
+
const here = [...projectRefs(project.root).values()]
|
|
780
|
+
const knowledgeHere = here.filter((r) => r.kind === 'knowledge').map((r) => r.name)
|
|
781
|
+
const basesHere = here.filter((r) => r.kind === 'knowledge-base' && `${KNOWLEDGE_BASE_DIR}/${r.name}` !== KNOWLEDGE_SETTINGS_REF).map((r) => `${KNOWLEDGE_BASE_DIR}/${r.name}`)
|
|
782
|
+
const isBase = (a: string) => a.startsWith(`${KNOWLEDGE_BASE_DIR}/`)
|
|
783
|
+
const documentNames = asked.includes('*') ? knowledgeHere : asked.filter((a) => !isBase(a)).map((a) => a.replace(/^knowledge\//, ''))
|
|
784
|
+
const documentBases = asked.includes('*') ? basesHere : asked.filter(isBase)
|
|
785
|
+
const notHere = [...documentNames.filter((n) => !knowledgeHere.includes(n)), ...documentBases.filter((r) => !basesHere.includes(r))]
|
|
786
|
+
if (notHere.length) throw new UsageError(`no knowledge base ${notHere.join(', ')} in this project`, '--documents takes knowledge base names (knowledge/<name>, or knowledge-base/<folder> for the Knowledge app), or * for every one')
|
|
741
787
|
const api = await platformFor(target)
|
|
742
|
-
|
|
743
|
-
|
|
788
|
+
// The Knowledge app's bases are read, proposed and applied as the person signed in, never with the project's keys.
|
|
789
|
+
const signedIn = here.some((r) => r.kind === 'knowledge-base') || plan.some((p) => p.kind === 'knowledge-base') ? await personReader(target.owner, link) : null
|
|
790
|
+
const documents = documentNames.length || documentBases.length
|
|
791
|
+
? {
|
|
792
|
+
names: new Set([...documentNames, ...documentBases]),
|
|
793
|
+
pulled: readPulledDocuments(target.state, link.workspace).bases,
|
|
794
|
+
platform: new Map([...(await platformDocuments(api, documentNames, remote.items)), ...(await baseDocumentsOn(signedIn, documentBases, remote.items))]),
|
|
795
|
+
}
|
|
744
796
|
: undefined
|
|
745
797
|
const all = pushCandidates({ root: project.root, plan, lock, snapshot, remote: remote.items, named, ...(documents ? { documents } : {}) })
|
|
798
|
+
if (!signedIn) {
|
|
799
|
+
const signIn = signInCommand(api.acting.profile)
|
|
800
|
+
for (const i of all) {
|
|
801
|
+
if (i.kind !== 'knowledge-base' || !i.proposable) continue
|
|
802
|
+
Object.assign(i, { proposable: false, blocked: 'sign-in', reason: `This workspace’s knowledge bases need a sign-in: run \`${signIn}\`, then push again`, fix: `sign in with \`${signIn}\`` })
|
|
803
|
+
}
|
|
804
|
+
}
|
|
746
805
|
// A named target is never pulled from: what it changed since this project delivered it has its own words.
|
|
747
806
|
const changedThere = target.name ? plan.filter((p) => p.status === 'changed').map((p) => ({ ref: p.ref, kind: p.kind, title: p.title })) : []
|
|
748
807
|
if (target.name) {
|
|
@@ -764,7 +823,8 @@ export async function syncPush(ctx: CommandContext): Promise<{ data: unknown; te
|
|
|
764
823
|
const known = snapshot.names?.model ? new Set(Object.keys(snapshot.names.model)) : null
|
|
765
824
|
const missing = known ? markMissingModels(all, known) : new Map<string, string[]>()
|
|
766
825
|
const checked = available ? await precheck(api, all, organizationKeyOf(api.acting), target) : { checked: false, refused: [] as string[], plan: [] as ApplyStep[] }
|
|
767
|
-
|
|
826
|
+
// Sources the project's Knowledge settings keep off: the platform's checks pass them, and Apply refuses them.
|
|
827
|
+
const person = [...personNeeds(checked.plan, api.acting), ...sourcesOffNeeds(project.root).filter((n) => all.some((i) => i.ref === n.ref && i.proposable))]
|
|
768
828
|
const kept = currentRequest(readRequests(target.state, link.workspace))
|
|
769
829
|
const updating = kept && isOpen(kept.state) ? kept : null
|
|
770
830
|
const orgStored = !!getSyncKey(target.owner, 'organization')
|
|
@@ -835,7 +895,7 @@ export async function syncPush(ctx: CommandContext): Promise<{ data: unknown; te
|
|
|
835
895
|
// Named items must each be proposable; with none named, an item the workspace moved past stops the push.
|
|
836
896
|
const stopping = chosen.filter((i) => !i.proposable && (named.length > 0 || i.blocked === 'moved' || i.blocked === 'conflicts'))
|
|
837
897
|
if (stopping.length) {
|
|
838
|
-
const lines = stopping.map((i) => ` ${
|
|
898
|
+
const lines = stopping.map((i) => ` ${labelOf(i)}: ${i.reason}`)
|
|
839
899
|
throw new CliError(`not proposed: ${plural(stopping.length, 'item')} cannot go yet\n${lines.join('\n')}`, {
|
|
840
900
|
code: 'SYNC_PUSH_BLOCKED',
|
|
841
901
|
hint: target.name && stopping.some((i) => i.blocked === 'moved')
|
|
@@ -852,7 +912,7 @@ export async function syncPush(ctx: CommandContext): Promise<{ data: unknown; te
|
|
|
852
912
|
// Changes the checks refused are changes still: say why each can't go, not that nothing changed.
|
|
853
913
|
const held = chosen.filter((i) => !i.proposable && !i.sample)
|
|
854
914
|
if (held.length) {
|
|
855
|
-
throw new CliError(`nothing to propose: ${plural(held.length, 'change')} here cannot go yet\n${held.flatMap((i) => [` ${
|
|
915
|
+
throw new CliError(`nothing to propose: ${plural(held.length, 'change')} here cannot go yet\n${held.flatMap((i) => [` ${labelOf(i)}: ${i.reason}`, ...(i.fix ? [` Fix: ${i.fix}`] : [])]).join('\n')}`, {
|
|
856
916
|
code: 'SYNC_PUSH_BLOCKED',
|
|
857
917
|
hint: `fix ${held.length === 1 ? 'it' : 'them'} as said, then check with \`${command('push --preview')}\``,
|
|
858
918
|
details: { blocked: held.map((i) => ({ ref: i.ref, code: i.blocked, message: i.reason, ...(i.fix ? { fix: i.fix } : {}) })) },
|
|
@@ -916,16 +976,18 @@ export async function syncPush(ctx: CommandContext): Promise<{ data: unknown; te
|
|
|
916
976
|
notProposed: notProposed.map((i) => ({ ref: i.ref, code: i.blocked, message: i.reason, ...(i.fix ? { fix: i.fix } : {}) })),
|
|
917
977
|
samples: leftSamples.map((i) => i.ref),
|
|
918
978
|
...stewardData(api.acting, needsOrganizationKey(view.items), orgStored),
|
|
979
|
+
needsPerson: person.filter((p) => going.some((i) => i.ref === p.ref)),
|
|
919
980
|
notCarried,
|
|
920
981
|
},
|
|
921
982
|
text: [
|
|
922
983
|
`${open ? `Updated “${view.request.title}” in` : `Proposed “${view.request.title}” to`} ${targetLabel(target)}: ${plural(view.items.length, 'item')}.`,
|
|
923
984
|
...going.map((i) => ` ${pushItemLine(i)}`),
|
|
924
985
|
...releases.map((r) => ` Custom App ${r.slug}: release ${r.deployment}${r.chosen ? ' (its newest deployment)' : ''}; Apply promotes it last`),
|
|
925
|
-
...(notProposed.length ? ['', 'Not proposed:', ...notProposed.flatMap((i) => [` ${
|
|
986
|
+
...(notProposed.length ? ['', 'Not proposed:', ...notProposed.flatMap((i) => [` ${labelOf(i)}: ${i.reason}`, ...(i.fix ? [` Fix: ${i.fix}`] : [])])] : []),
|
|
926
987
|
...changedThereLines(target, changedThere),
|
|
927
988
|
...(leftSamples.length ? ['', `Left out: ${plural(leftSamples.length, 'sample')} from setting up the project (${leftSamples.map((i) => i.title).join(', ')}); name ${leftSamples.length === 1 ? 'it' : 'them'} to propose`] : []),
|
|
928
989
|
...(needsOrganizationKey(view.items) ? ['', stewardNote(api.acting, true, pushAgain(api.acting, view))] : []),
|
|
990
|
+
...needLines(person.filter((p) => going.some((i) => i.ref === p.ref))),
|
|
929
991
|
...(mcp ? ['', mcp.line] : []),
|
|
930
992
|
'',
|
|
931
993
|
`Apply it with \`${command('request apply')}\`; see it with \`${command('request')}\`.`,
|
|
@@ -1072,6 +1134,18 @@ async function currentView(target: SyncTarget): Promise<{ view: View; titles: Re
|
|
|
1072
1134
|
* request proposed before items were named by display name kept "brand"),
|
|
1073
1135
|
* then what its proposed files declare. The kept titles are not rewritten.
|
|
1074
1136
|
*/
|
|
1137
|
+
/** Whether an Agent's file sets its knowledge bases, which Apply sets as the person applying. */
|
|
1138
|
+
function picksIn(text: string | undefined): boolean {
|
|
1139
|
+
try {
|
|
1140
|
+
return Array.isArray((JSON.parse(text ?? '') as { knowledgeBases?: unknown }).knowledgeBases)
|
|
1141
|
+
} catch {
|
|
1142
|
+
return false
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
/** Each item's title by ref, as the request's lines name them. */
|
|
1147
|
+
const allTitlesOf = (view: View, titles: Record<string, string>) => Object.fromEntries(view.items.map((i) => [i.ref, titleOf(i, titles)]))
|
|
1148
|
+
|
|
1075
1149
|
function titleOf(item: ViewItem, titles: Record<string, string>): string {
|
|
1076
1150
|
if (item.kind === 'app') return `${item.name} ${releasedDeployment(item) ?? ''}`.trim()
|
|
1077
1151
|
return itemTitle(item.kind, item.name, item.files, titles[item.ref])
|
|
@@ -1178,17 +1252,28 @@ async function apply(ctx: CommandContext) {
|
|
|
1178
1252
|
if (view.request.state === 'merged') throw new CliError(`“${view.request.title}” is already applied`, { code: 'CONFLICT', hint: `propose new changes with \`${command('push -t "<title>"')}\`` })
|
|
1179
1253
|
if (view.request.state === 'closed') throw new CliError(`“${view.request.title}” is closed`, { code: 'CONFLICT', hint: `propose the changes again with \`${command('push -t "<title>"')}\`` })
|
|
1180
1254
|
const proposed: ProposedBy = proposedByOther(acting, view) === 'key' ? 'key' : 'you'
|
|
1181
|
-
|
|
1255
|
+
const stewardMatters = acting.as === 'person' && acting.organizationKeyStored && view.items.some((i) => i.kind === 'blueprint' || i.kind === 'agent')
|
|
1256
|
+
// A knowledge base, the Knowledge settings and an Agent's picks are applied by a person who may change them.
|
|
1257
|
+
const knowledgeMatters = view.items.some((i) => i.kind === 'knowledge-base' || (i.kind === 'agent' && picksIn(i.files[`agents/${i.name}/agent.json`])))
|
|
1258
|
+
if (stewardMatters || knowledgeMatters) {
|
|
1182
1259
|
// The platform refuses a person who is no Steward only once Apply runs, so ask its checks, with every item so references resolve.
|
|
1183
|
-
const items = view.items.map((i) => ({ ref: i.ref, files: i.files, ...(i.baseRevision ? { baseRevision: i.baseRevision } : {}), ...(i.deleted ? { deleted: true } : {}) }))
|
|
1260
|
+
const items = view.items.map((i) => ({ ref: i.ref, ...(i.resourceId && i.kind === 'knowledge-base' ? { id: i.resourceId } : {}), files: i.files, ...(i.baseRevision ? { baseRevision: i.baseRevision } : {}), ...(i.deleted ? { deleted: true } : {}) }))
|
|
1184
1261
|
const plan = await api.call<unknown>('POST', '/v1/change-requests/check', { items }, { timeoutMs: 120_000 }).then(planOf, () => [])
|
|
1185
|
-
const stop = notSteward(acting, stewardStepUnmet(needsAPerson(plan), view.items), pushAgain(acting, view), proposed)
|
|
1262
|
+
const stop = stewardMatters ? notSteward(acting, stewardStepUnmet(needsAPerson(plan), view.items), pushAgain(acting, view), proposed) : null
|
|
1186
1263
|
if (stop) throw stop
|
|
1264
|
+
const unmet = personNeeds(plan, acting).filter((n) => n.code.startsWith('knowledge_') && !n.after)
|
|
1265
|
+
if (unmet.length) {
|
|
1266
|
+
throw new CliError(`not applied: ${unmet.map((n) => `${allTitlesOf(view, titles)[n.ref] ?? n.ref}: ${n.message}`).join('; ')}`, {
|
|
1267
|
+
code: unmet.some((n) => n.code === 'knowledge_needs_person') ? 'SYNC_NOT_SIGNED_IN' : 'FORBIDDEN',
|
|
1268
|
+
hint: unmet[0]!.fix,
|
|
1269
|
+
details: { needsPerson: unmet },
|
|
1270
|
+
})
|
|
1271
|
+
}
|
|
1187
1272
|
}
|
|
1188
1273
|
const refused = notProposer(acting, view, 'apply')
|
|
1189
1274
|
// A workspace admin signed in may apply another's request once every item is approved: the platform decides.
|
|
1190
1275
|
if (refused && !(acting.as === 'person' && view.request.state === 'approved')) throw refused
|
|
1191
|
-
const allTitles =
|
|
1276
|
+
const allTitles = allTitlesOf(view, titles)
|
|
1192
1277
|
const blueprint = view.items.some((i) => i.kind === 'blueprint')
|
|
1193
1278
|
// With the keys, sent whenever one is stored: an Agent's Blueprint grants are a Steward's too, in a request without Blueprint files (P5 A5).
|
|
1194
1279
|
const organizationKey = organizationKeyOf(acting)
|
|
@@ -1243,7 +1328,9 @@ async function apply(ctx: CommandContext) {
|
|
|
1243
1328
|
try {
|
|
1244
1329
|
// A deletion shows only in a full read of the workspace's index (an incremental one lists no deletions).
|
|
1245
1330
|
const deleted = answer.items.filter((i) => i.deleted).map((i) => i.ref)
|
|
1246
|
-
|
|
1331
|
+
// A knowledge base keeps the folder it was proposed from, whatever name the workspace files it under (one just created too).
|
|
1332
|
+
const folders = new Map(answer.items.flatMap((i) => (i.kind === 'knowledge-base' && i.resourceId && !i.deleted && i.ref !== KNOWLEDGE_SETTINGS_REF ? [[i.resourceId, i.name] as const] : [])))
|
|
1333
|
+
const result = await fetchTarget(project, target, { full: deleted.length > 0, knowledgeBaseFolders: folders })
|
|
1247
1334
|
// A Custom App release moved a pointer; the App's own sync item (its description) is not the project's to adopt.
|
|
1248
1335
|
const adoptable = answer.items.filter((i) => i.kind !== 'app').map((i) => i.ref)
|
|
1249
1336
|
// A named target's base is its own (`target.state`), so the next push to it reads what changed there since.
|
|
@@ -1295,6 +1382,7 @@ async function apply(ctx: CommandContext) {
|
|
|
1295
1382
|
*/
|
|
1296
1383
|
async function recordAppliedDocuments(target: SyncTarget, api: Platform, view: View): Promise<void> {
|
|
1297
1384
|
const { link } = target
|
|
1385
|
+
await recordAppliedBaseDocuments(target, view)
|
|
1298
1386
|
const items = view.items.filter((i) => i.kind === 'knowledge' && i.documents)
|
|
1299
1387
|
if (!items.length) return
|
|
1300
1388
|
const snapshot = readSnapshot(target.state)
|
|
@@ -1322,6 +1410,24 @@ async function recordAppliedDocuments(target: SyncTarget, api: Platform, view: V
|
|
|
1322
1410
|
}
|
|
1323
1411
|
}
|
|
1324
1412
|
|
|
1413
|
+
/** The same for the Knowledge app's bases, read as the person signed in, recorded under `knowledge-base/<folder>`. */
|
|
1414
|
+
async function recordAppliedBaseDocuments(target: SyncTarget, view: View): Promise<void> {
|
|
1415
|
+
const items = view.items.filter((i) => i.kind === 'knowledge-base' && i.documents && i.resourceId)
|
|
1416
|
+
if (!items.length) return
|
|
1417
|
+
const person = await personReader(target.owner, target.link)
|
|
1418
|
+
if (!person) return
|
|
1419
|
+
for (const item of items) {
|
|
1420
|
+
const rows = await knowledgeBaseDocumentsOn(person.workspace, item.resourceId!)
|
|
1421
|
+
const entries: Record<string, PulledDocument | null> = {}
|
|
1422
|
+
for (const d of item.documents!.upload) {
|
|
1423
|
+
const newest = rows.filter((r) => r.fileName === d.fileName && !r.synced).sort((a, b) => String(b.createdAt ?? '').localeCompare(String(a.createdAt ?? '')))[0]
|
|
1424
|
+
if (newest) entries[d.fileName] = { sourceId: newest.id, sha256: d.sha256, bytes: d.bytes, at: new Date().toISOString() }
|
|
1425
|
+
}
|
|
1426
|
+
for (const d of item.documents!.remove) entries[d.fileName] = null
|
|
1427
|
+
recordPulledDocuments(target.state, target.link.workspace, item.ref, entries)
|
|
1428
|
+
}
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1325
1431
|
async function close(ctx: CommandContext) {
|
|
1326
1432
|
const project = localProject(ctx.cwd)
|
|
1327
1433
|
const target = chooseTarget(project, ctx)
|