@frontera-sdk/cli 1.53.5 → 1.53.7
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/apps-api.ts +2 -2
- package/src/api/blueprint-authoring-api.ts +19 -19
- package/src/api/governed-action-api.ts +78 -3
- package/src/api/platform-api.ts +20 -0
- package/src/blueprint/dataset-revision.ts +66 -0
- package/src/blueprint/diff.ts +134 -6
- package/src/blueprint/publish-plan.ts +253 -0
- package/src/blueprint/render.ts +19 -0
- package/src/commands/action/deploy.ts +143 -25
- package/src/commands/action/index-commands.ts +3 -3
- package/src/commands/action/list.ts +3 -3
- package/src/commands/action/prepare.ts +5 -3
- package/src/commands/action/retire.ts +78 -26
- package/src/commands/action/review.ts +86 -25
- package/src/commands/app/checks.ts +1 -1
- package/src/commands/app/deploy.ts +9 -9
- package/src/commands/app/deployments.ts +1 -1
- package/src/commands/app/dev.ts +79 -14
- package/src/commands/app/promote.ts +1 -1
- package/src/commands/app/pull.ts +4 -2
- package/src/commands/app/rollback.ts +1 -1
- package/src/commands/app/save.ts +3 -7
- package/src/commands/app/shared.ts +59 -2
- package/src/commands/app/unpin.ts +1 -1
- package/src/commands/app/versions.ts +1 -1
- package/src/commands/blueprint/authoring.ts +110 -24
- package/src/commands/blueprint/bind.ts +10 -46
- package/src/commands/blueprint/declarative.ts +124 -30
- package/src/commands/blueprint/editable.ts +3 -5
- package/src/commands/knowledge/index-commands.ts +15 -2
- package/src/commands/local/check.ts +21 -13
- package/src/commands/local/data.ts +1 -1
- package/src/commands/local/dev.ts +1 -21
- package/src/commands/local/index-commands.ts +1 -1
- package/src/commands/local/snapshot.ts +2 -2
- package/src/commands/local/steps.ts +11 -7
- package/src/commands/local/sync-request.ts +45 -8
- package/src/commands/local/sync.ts +23 -4
- package/src/commands/mcp-server/index-commands.ts +99 -11
- package/src/commands/registry.ts +1 -1
- package/src/commands/types.ts +2 -0
- package/src/device-login.ts +19 -1
- package/src/exit.ts +5 -0
- package/src/flag-help.ts +2 -1
- package/src/forge/permission-rules.ts +2 -0
- package/src/local/local-actions.ts +6 -5
- package/src/local/local-app-runtime.ts +6 -4
- package/src/local/local-blueprint-schemas.ts +2 -2
- package/src/local/local-build-inputs.ts +2 -1
- package/src/local/local-db-snapshot.ts +62 -46
- package/src/local/local-dev-references.ts +0 -23
- package/src/local/local-docker.ts +84 -10
- package/src/local/local-mcp-servers.ts +79 -14
- package/src/local/local-person-steps.ts +121 -19
- package/src/local/local-project.ts +5 -1
- package/src/local/local-supervisor.ts +41 -3
- package/src/local/local-sync-link.ts +76 -0
- package/src/local/local-sync-precheck.ts +1 -1
- package/src/local/local-sync-push.ts +8 -2
- package/src/local/local-sync-remote.ts +18 -0
- package/src/main.ts +7 -1
- package/src/mcp/mcp-apps.ts +96 -20
- package/src/mcp/tools/agent-tools.ts +29 -7
- package/src/mcp/tools/app-tools.ts +3 -3
- package/src/mcp/tools/project-tools.ts +2 -1
- package/src/project.ts +47 -11
- package/src/remote-guard.ts +4 -1
- package/src/templates/next-app-files.ts +4 -1
- package/src/templates/next-skills.ts +9 -5
- package/src/vendor/kit-assets.json +8 -8
- package/src/vendor/sdk-sources.json +17 -17
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { AppsApi } from '../../api/apps-api'
|
|
2
|
+
import { PlatformApi } from '../../api/platform-api'
|
|
2
3
|
import { UsageError } from '../../errors'
|
|
4
|
+
import { appBinding, appTarget, bindApp } from '../../project'
|
|
3
5
|
import type { AppProject } from '../../context'
|
|
4
6
|
import type { CommandContext } from '../types'
|
|
5
7
|
|
|
@@ -13,6 +15,16 @@ export function requireProjectFrom(ctx: CommandContext): AppProject {
|
|
|
13
15
|
return ctx.project
|
|
14
16
|
}
|
|
15
17
|
|
|
18
|
+
/**
|
|
19
|
+
* The target this credential reaches (`appTarget`): its workspace as `whoami`
|
|
20
|
+
* answers, asked the way `AppsApi` asks, so it is the workspace the App calls
|
|
21
|
+
* land in. Null when it cannot say; the folder's bindings are then left alone.
|
|
22
|
+
*/
|
|
23
|
+
export async function credentialTarget(ctx: CommandContext): Promise<string | null> {
|
|
24
|
+
const me = await new PlatformApi(ctx.apiUrl, ctx.token).whoami().catch(() => null)
|
|
25
|
+
return me?.workspaceId ? appTarget(ctx.apiUrl, me.workspaceId) : null
|
|
26
|
+
}
|
|
27
|
+
|
|
16
28
|
/**
|
|
17
29
|
* The app this project belongs to, without creating one.
|
|
18
30
|
*
|
|
@@ -20,6 +32,51 @@ export function requireProjectFrom(ctx: CommandContext): AppProject {
|
|
|
20
32
|
* that ensured instead would turn a typo, or a slug the platform had since
|
|
21
33
|
* renamed, into a brand new empty app.
|
|
22
34
|
*/
|
|
23
|
-
export function projectApp(client: AppsApi, project: AppProject) {
|
|
24
|
-
|
|
35
|
+
export async function projectApp(ctx: CommandContext, client: AppsApi, project: AppProject) {
|
|
36
|
+
const bound = appBinding(project.root, await credentialTarget(ctx)).id
|
|
37
|
+
return client.resolveApp(bound ?? project.fallbackAppId ?? project.slug)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The folder's App where the credential acts, created when it has none there
|
|
42
|
+
* (`save`, `deploy`, `app dev`), and bound to the folder for that target. A
|
|
43
|
+
* creation is said: a person who renamed the folder or moved it to another
|
|
44
|
+
* workspace must not learn of a second App from the gallery. `app` names an
|
|
45
|
+
* existing App to deploy to instead, which never creates one.
|
|
46
|
+
*/
|
|
47
|
+
export async function ensureProjectApp(
|
|
48
|
+
ctx: CommandContext,
|
|
49
|
+
client: AppsApi,
|
|
50
|
+
project: AppProject,
|
|
51
|
+
app?: string,
|
|
52
|
+
): Promise<{ id: string; slug: string; created: string | null; target: string | null }> {
|
|
53
|
+
const target = await credentialTarget(ctx)
|
|
54
|
+
const known = appBinding(project.root, target)
|
|
55
|
+
const named = app ? await client.resolveApp(app) : null
|
|
56
|
+
// Refused before the folder is bound to it: every later deploy would send its id, and fail the same way.
|
|
57
|
+
if (named?.kind && named.kind !== 'code') {
|
|
58
|
+
throw new UsageError(`${named.slug} is a Chat App, and a folder deploys to a Custom App`, '`frontera app list` names the Apps; pass a Custom App\'s slug to `--app`, or leave `--app` out to deploy to the folder\'s own')
|
|
59
|
+
}
|
|
60
|
+
const ensured = named
|
|
61
|
+
? { ...named, created: false }
|
|
62
|
+
: await client.ensureApp(project.slug, project.displayName, known.id ?? project.fallbackAppId, { icon: project.icon })
|
|
63
|
+
bindApp(project.root, target, ensured.id)
|
|
64
|
+
const created = ensured.created ? createdApp(ensured.slug, known.id ? 'gone' : known.elsewhere || project.fallbackAppId ? 'elsewhere' : 'first') : null
|
|
65
|
+
if (created) ctx.output.note(` ${created}`)
|
|
66
|
+
// The platform owns the name once the App exists (App settings renames it); the folder's name only creates one.
|
|
67
|
+
else if (ensured.slug !== project.slug) ctx.output.note(` bound to the App ${ensured.slug} here: this folder's own name, ${project.slug}, names an App only on its first deploy to a workspace and never renames one (rename it in Apps, App settings)`)
|
|
68
|
+
return { id: ensured.id, slug: ensured.slug, created, target }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* What a creation says: plain for a folder's first deploy anywhere. A folder
|
|
73
|
+
* that has an App elsewhere, or had one here, gets the way back too, since
|
|
74
|
+
* this workspace may hold its App under another name.
|
|
75
|
+
*/
|
|
76
|
+
function createdApp(slug: string, before: 'first' | 'gone' | 'elsewhere'): string {
|
|
77
|
+
if (before === 'first') return `created the App ${slug} in this workspace`
|
|
78
|
+
const why = before === 'gone' ? ': the App this folder deployed to here is gone' : ', this folder\'s first deploy here (it has deployed to other workspaces)'
|
|
79
|
+
return `created a new App ${slug} in this workspace${why}. `
|
|
80
|
+
+ 'If this workspace already had the App under another name, deploy to that one instead with `frontera app deploy --app <slug>` (`frontera app list` names them) '
|
|
81
|
+
+ `and archive ${slug} in Apps. A deploy never renames an App: rename one in Apps, App settings.`
|
|
25
82
|
}
|
|
@@ -20,7 +20,7 @@ export const appUnpin: Command = {
|
|
|
20
20
|
if (!reason?.trim()) throw new UsageError('unpinning needs --reason', 'frontera app unpin --reason "why production may move again"')
|
|
21
21
|
const project = requireProjectFrom(ctx)
|
|
22
22
|
const client = new AppsApi(ctx.apiUrl, ctx.token)
|
|
23
|
-
const app = await projectApp(client, project)
|
|
23
|
+
const app = await projectApp(ctx, client, project)
|
|
24
24
|
const res = await client.unpin(app.id, reason)
|
|
25
25
|
return { data: { app: app.slug, appId: app.id, ...res }, text: `Unpinned ${app.slug}: production keeps serving ${res.productionLabel ?? res.production ?? 'what it serves'}, and promotion works again` }
|
|
26
26
|
},
|
|
@@ -17,7 +17,7 @@ export const appVersions: Command = {
|
|
|
17
17
|
async run(ctx) {
|
|
18
18
|
const project = requireProjectFrom(ctx)
|
|
19
19
|
const client = new AppsApi(ctx.apiUrl, ctx.token)
|
|
20
|
-
const app = await projectApp(client, project)
|
|
20
|
+
const app = await projectApp(ctx, client, project)
|
|
21
21
|
const versions = await client.listVersions(app.id)
|
|
22
22
|
|
|
23
23
|
return {
|
|
@@ -2,6 +2,16 @@ import { readFileSync } from 'node:fs'
|
|
|
2
2
|
import { resolve } from 'node:path'
|
|
3
3
|
|
|
4
4
|
import { BlueprintAuthoringApi } from '../../api/blueprint-authoring-api'
|
|
5
|
+
import { GovernedActionApi } from '../../api/governed-action-api'
|
|
6
|
+
import {
|
|
7
|
+
blockerLines,
|
|
8
|
+
groupedChanges,
|
|
9
|
+
migrationInstructions,
|
|
10
|
+
newActionSteps,
|
|
11
|
+
changedActionSteps,
|
|
12
|
+
publishPlan,
|
|
13
|
+
} from '../../blueprint/publish-plan'
|
|
14
|
+
import { signInStep } from '../../device-login'
|
|
5
15
|
import { CliError } from '../../errors'
|
|
6
16
|
import type { Command, CommandContext } from '../types'
|
|
7
17
|
|
|
@@ -301,7 +311,7 @@ type ValidationReport = Awaited<ReturnType<BlueprintAuthoringApi['validate']>>
|
|
|
301
311
|
* release to the validation that cleared it rather than to whatever the draft
|
|
302
312
|
* holds later.
|
|
303
313
|
*/
|
|
304
|
-
export function renderValidationReport(report: ValidationReport): string {
|
|
314
|
+
export function renderValidationReport(report: Partial<ValidationReport>): string {
|
|
305
315
|
const id = report?.id ?? report?.reportId
|
|
306
316
|
const objectTypes = report?.catalogProjection?.objectTypes?.length ?? 0
|
|
307
317
|
const linkTypes = report?.catalogProjection?.linkTypes?.length ?? 0
|
|
@@ -327,7 +337,16 @@ export const blueprintPublish: Command = {
|
|
|
327
337
|
verb: 'publish',
|
|
328
338
|
args: [],
|
|
329
339
|
flags: { label: 'string', notes: 'string', report: 'string', instruction: 'string' },
|
|
340
|
+
flagHelp: {
|
|
341
|
+
instruction: 'how existing data and environments take the changes that need a migration instruction; sent with each of them (a new Action’s own is written for you)',
|
|
342
|
+
report: 'the draft’s latest validation report to publish against; without it, publish validates the draft first',
|
|
343
|
+
},
|
|
330
344
|
summary: 'Publish the shared draft as a release',
|
|
345
|
+
details: [
|
|
346
|
+
'Validates the draft first and posts nothing that would be refused: each blocker is listed with the command that clears it.',
|
|
347
|
+
'A new governed Action with no deployment decision is recorded as shipping switched off, as `frontera action prepare` does.',
|
|
348
|
+
'A change that needs a migration instruction (other than a new Action\'s) is listed by kind and name; --instruction explains every one of them.',
|
|
349
|
+
],
|
|
331
350
|
examples: [
|
|
332
351
|
'frontera blueprint publish --label v3 --notes "Adds Customer"',
|
|
333
352
|
'frontera blueprint publish --label v4 --instruction "Backfilled"',
|
|
@@ -337,39 +356,106 @@ export const blueprintPublish: Command = {
|
|
|
337
356
|
const client = api(ctx)
|
|
338
357
|
const label = (ctx.flags.label as string) ?? 'cli-release'
|
|
339
358
|
const notes = (ctx.flags.notes as string) ?? 'Published with the Frontera CLI.'
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
359
|
+
const instruction = (ctx.flags.instruction as string | undefined)?.trim() || undefined
|
|
360
|
+
const profile = typeof ctx.flags.profile === 'string' ? ctx.flags.profile : undefined
|
|
361
|
+
|
|
362
|
+
// `--report` is the validation the person read; without it, the draft is validated now.
|
|
363
|
+
let report: ValidationReport
|
|
364
|
+
const reportId = ctx.flags.report as string | undefined
|
|
365
|
+
if (reportId) {
|
|
366
|
+
const latest = (await client.lifecycle() as { latestValidation?: ValidationReport | null }).latestValidation
|
|
367
|
+
if (latest?.id !== reportId) {
|
|
368
|
+
throw new CliError(`Report ${reportId} is not the draft's latest validation, so a release from it would not be what was checked.`, {
|
|
369
|
+
code: 'VALIDATION_STALE',
|
|
370
|
+
hint: 'drop --report (publish validates the draft itself), or run `frontera blueprint validate` and pass its report',
|
|
371
|
+
})
|
|
372
|
+
}
|
|
373
|
+
report = latest
|
|
374
|
+
} else {
|
|
375
|
+
report = await client.validate()
|
|
376
|
+
}
|
|
377
|
+
const signIn = await signInStep({ profile: ctx.profile, apiUrl: ctx.apiUrl })
|
|
378
|
+
let plan = publishPlan(report, signIn, profile)
|
|
379
|
+
|
|
380
|
+
// What `action prepare` records, for each Action that has no decision yet.
|
|
381
|
+
const prepared: string[] = []
|
|
382
|
+
if (plan.autoFixable.length) {
|
|
383
|
+
const actions = new GovernedActionApi(ctx.apiUrl, ctx.token, ctx.workspaceId)
|
|
384
|
+
for (const name of plan.autoFixable) {
|
|
385
|
+
await actions.recordDeploymentDecision(name, `Published with the Frontera CLI ("${label}"): ships switched off until a person deploys it and turns it on.`)
|
|
386
|
+
prepared.push(name)
|
|
387
|
+
}
|
|
388
|
+
plan = publishPlan(await client.validate(), signIn, profile)
|
|
389
|
+
}
|
|
390
|
+
const preparedLines = prepared.map((name) => `Recorded that Action ${name} ships switched off: a person deploys it and turns it on after publish.`)
|
|
391
|
+
|
|
392
|
+
if (plan.blockers.length) {
|
|
393
|
+
throw new CliError([
|
|
394
|
+
...preparedLines,
|
|
395
|
+
`The draft can't be published yet, so nothing was published. ${plan.blockers.length} blocker${plan.blockers.length === 1 ? '' : 's'}:`,
|
|
396
|
+
...blockerLines(plan.blockers),
|
|
397
|
+
].join('\n'), {
|
|
398
|
+
code: 'RELEASE_BLOCKED',
|
|
399
|
+
hint: 'clear each blocker above, then publish again',
|
|
400
|
+
details: { prepared, plan },
|
|
401
|
+
})
|
|
347
402
|
}
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
403
|
+
|
|
404
|
+
if (plan.instructionsNeeded.length && !instruction) {
|
|
405
|
+
const quote = (value: string) => (/^[\w.:-]+$/.test(value) ? value : JSON.stringify(value))
|
|
406
|
+
const rerun = ['frontera blueprint publish', `--label ${quote(label)}`, ...(ctx.flags.notes ? [`--notes ${quote(notes)}`] : []), '--instruction "<how existing data and environments take these changes>"', ...(profile ? [`--profile ${profile}`] : [])].join(' ')
|
|
407
|
+
const count = plan.instructionsNeeded.length
|
|
408
|
+
throw new CliError([
|
|
409
|
+
...preparedLines,
|
|
410
|
+
`${count} change${count === 1 ? '' : 's'} in this release need${count === 1 ? 's' : ''} a migration instruction, so nothing was published:`,
|
|
411
|
+
...groupedChanges(plan.instructionsNeeded).map((line) => ` ${line}`),
|
|
412
|
+
].join('\n'), {
|
|
413
|
+
code: 'MIGRATION_INSTRUCTION_REQUIRED',
|
|
414
|
+
hint: `say how existing data and environments take these changes; the instruction is kept with each change in the release:\n ${rerun}`,
|
|
415
|
+
details: { prepared, plan },
|
|
352
416
|
})
|
|
353
417
|
}
|
|
354
418
|
|
|
419
|
+
// Refused by the service, and kept as a failed attempt, if posted.
|
|
420
|
+
if (plan.unchanged) {
|
|
421
|
+
return { data: { release: null, prepared, newActions: [] }, text: [...preparedLines, 'The draft has no unpublished changes, so nothing was published.'].join('\n') }
|
|
422
|
+
}
|
|
423
|
+
|
|
355
424
|
const released = await client.publish({
|
|
356
|
-
expectedRevision:
|
|
357
|
-
validationReportId,
|
|
425
|
+
expectedRevision: plan.draftRevision,
|
|
426
|
+
validationReportId: plan.reportId,
|
|
358
427
|
releaseLabel: label,
|
|
359
428
|
releaseNotes: notes,
|
|
360
|
-
|
|
361
|
-
instruction: ctx.flags.instruction as string | undefined,
|
|
429
|
+
migrationInstructions: migrationInstructions(plan, instruction),
|
|
362
430
|
})
|
|
363
431
|
const lifecycle = released as { activeRelease?: { releaseLabel?: string }; alreadyPublished?: boolean } | null
|
|
364
|
-
if (
|
|
365
|
-
|
|
432
|
+
if (lifecycle?.alreadyPublished) {
|
|
433
|
+
// A retry of a publish that already went through is answered with the release
|
|
434
|
+
// that is live, which carries the label it was published with, not this run's.
|
|
435
|
+
// One made after newer Draft edits is refused by the service as stale.
|
|
436
|
+
const activeReleaseLabel = lifecycle.activeRelease?.releaseLabel ?? label
|
|
437
|
+
return { data: { release: released, prepared, newActions: [] }, text: `Release "${activeReleaseLabel}" is already active; nothing was published.` }
|
|
438
|
+
}
|
|
439
|
+
const newActions = plan.newActions.map((action) => ({ ...action, nextSteps: newActionSteps(action, signIn, profile) }))
|
|
440
|
+
const changedActions = plan.changedSwitchedOff.map((action) => ({ apiName: action.apiName, nextSteps: changedActionSteps(action, signIn, profile) }))
|
|
441
|
+
const numbered = (steps: Array<{ step: string; command?: string }>) => steps.flatMap((step, index) => (step.command ? [` ${index + 1}. ${step.step}:`, ` ${step.command}`] : [` ${index + 1}. ${step.step}.`]))
|
|
442
|
+
return {
|
|
443
|
+
data: { release: released, prepared, newActions, changedActions },
|
|
444
|
+
text: [
|
|
445
|
+
...preparedLines,
|
|
446
|
+
`Published release "${label}".`,
|
|
447
|
+
...newActions.flatMap((action) => [
|
|
448
|
+
'',
|
|
449
|
+
`New Action ${action.apiName} is published switched off, with no write path. To turn it on:`,
|
|
450
|
+
...numbered(action.nextSteps),
|
|
451
|
+
]),
|
|
452
|
+
...changedActions.flatMap((action) => [
|
|
453
|
+
'',
|
|
454
|
+
`Action ${action.apiName} changed and is published switched off; its old write path does not run the new version. To turn it on again:`,
|
|
455
|
+
...numbered(action.nextSteps),
|
|
456
|
+
]),
|
|
457
|
+
].join('\n'),
|
|
366
458
|
}
|
|
367
|
-
// A retry of a publish that already went through is answered with the release
|
|
368
|
-
// that is live, which carries the label it was published with, not this run's.
|
|
369
|
-
// One made after newer Draft edits is refused by the service as stale.
|
|
370
|
-
const activeReleaseLabel = lifecycle.activeRelease?.releaseLabel ?? label
|
|
371
|
-
const nothingPublished = `Release "${activeReleaseLabel}" is already active; nothing was published.`
|
|
372
|
-
return { data: released, text: nothingPublished }
|
|
373
459
|
},
|
|
374
460
|
}
|
|
375
461
|
|
|
@@ -2,7 +2,7 @@ import { existsSync, readFileSync, writeFileSync } from 'node:fs'
|
|
|
2
2
|
import { join, resolve } from 'node:path'
|
|
3
3
|
|
|
4
4
|
import { BlueprintAuthoringApi } from '../../api/blueprint-authoring-api'
|
|
5
|
-
import { pickCurrentRevision } from '../../blueprint/dataset-revision'
|
|
5
|
+
import { columnMappingsFrom, datasetBindingCommand, pickCurrentRevision, type MappingEntry } from '../../blueprint/dataset-revision'
|
|
6
6
|
import { readTree, writeTree } from '../../blueprint/tree'
|
|
7
7
|
import { CliError } from '../../errors'
|
|
8
8
|
import type { Command, CommandContext } from '../types'
|
|
@@ -31,7 +31,7 @@ function api(ctx: CommandContext): BlueprintAuthoringApi {
|
|
|
31
31
|
|
|
32
32
|
interface MappingFile {
|
|
33
33
|
dataset: string
|
|
34
|
-
mapping: Record<string,
|
|
34
|
+
mapping: Record<string, MappingEntry | null>
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
async function datasetRevision(client: BlueprintAuthoringApi, name: string) {
|
|
@@ -58,7 +58,7 @@ async function datasetRevision(client: BlueprintAuthoringApi, name: string) {
|
|
|
58
58
|
hint: 'Publish a revision of the dataset, then bind.',
|
|
59
59
|
})
|
|
60
60
|
}
|
|
61
|
-
return current
|
|
61
|
+
return { ...current, id: current.id }
|
|
62
62
|
}
|
|
63
63
|
|
|
64
64
|
/**
|
|
@@ -150,36 +150,7 @@ export const blueprintBind: Command = {
|
|
|
150
150
|
})
|
|
151
151
|
}
|
|
152
152
|
const mapping = JSON.parse(readFileSync(resolve(ctx.cwd, filePath), 'utf8')) as MappingFile
|
|
153
|
-
const
|
|
154
|
-
|
|
155
|
-
const columnMappings: Array<{ propertyId: string; column: string; field?: string | null }> = []
|
|
156
|
-
const missing: string[] = []
|
|
157
|
-
for (const property of properties) {
|
|
158
|
-
const name = property.apiName
|
|
159
|
-
// A calculated field (a location from latitude and longitude) reads no column.
|
|
160
|
-
if (!name || (property as { calculation?: unknown }).calculation) continue
|
|
161
|
-
const entry = mapping.mapping?.[name]
|
|
162
|
-
if (entry === undefined || entry === null) {
|
|
163
|
-
missing.push(name)
|
|
164
|
-
continue
|
|
165
|
-
}
|
|
166
|
-
const resolved = typeof entry === 'string' ? { column: entry } : entry
|
|
167
|
-
const propertyId = idByApiName.get(name)
|
|
168
|
-
if (!propertyId) {
|
|
169
|
-
// `!` sent `propertyId: undefined` and let the service answer for it. The
|
|
170
|
-
// property came from the same read as the mapping, so this means the draft
|
|
171
|
-
// moved mid-command.
|
|
172
|
-
throw new CliError(`Property "${name}" is no longer on "${apiName}".`, {
|
|
173
|
-
code: 'CONFLICT',
|
|
174
|
-
hint: 'Re-run `frontera blueprint bind … --plan` against the current draft.',
|
|
175
|
-
})
|
|
176
|
-
}
|
|
177
|
-
columnMappings.push({
|
|
178
|
-
propertyId,
|
|
179
|
-
column: resolved.column,
|
|
180
|
-
...(resolved.field === undefined || resolved.field === null ? {} : { field: resolved.field }),
|
|
181
|
-
})
|
|
182
|
-
}
|
|
153
|
+
const { columnMappings, unmapped: missing } = columnMappingsFrom(properties, mapping.mapping ?? {})
|
|
183
154
|
if (missing.length > 0) {
|
|
184
155
|
// Refused here rather than at the service: the same refusal, one round trip
|
|
185
156
|
// earlier, and it can name every unmapped property at once instead of the
|
|
@@ -193,20 +164,13 @@ export const blueprintBind: Command = {
|
|
|
193
164
|
)
|
|
194
165
|
}
|
|
195
166
|
|
|
196
|
-
const result = await client.draftCommand(
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
kind: 'dataset',
|
|
201
|
-
datasetRevisionId: revision.id,
|
|
202
|
-
columnMappings,
|
|
203
|
-
...(ctx.flags['accept-contract-change'] === true ? { acceptContractChange: true } : {}),
|
|
204
|
-
},
|
|
205
|
-
}, await client.revision())
|
|
167
|
+
const result = await client.draftCommand(
|
|
168
|
+
datasetBindingCommand(detail.id, revision.id, columnMappings, ctx.flags['accept-contract-change'] === true),
|
|
169
|
+
await client.revision(),
|
|
170
|
+
)
|
|
206
171
|
|
|
207
|
-
// The tree is told what the draft now says
|
|
208
|
-
//
|
|
209
|
-
// reconcile the file, and it would sit stale until the next `pull`.
|
|
172
|
+
// The tree is told what the draft now says, or the next `apply` would read the
|
|
173
|
+
// file's old Dataset as a rebind back to it.
|
|
210
174
|
const treeRoot = resolve(ctx.cwd, (ctx.flags.dir as string) ?? 'blueprint')
|
|
211
175
|
let treeUpdated = false
|
|
212
176
|
if (existsSync(join(treeRoot, 'object-types'))) {
|
|
@@ -23,15 +23,19 @@ import {
|
|
|
23
23
|
} from '../../blueprint/compile'
|
|
24
24
|
import { metricRuleProblems } from '../../blueprint/metric-rules'
|
|
25
25
|
import { actionRuleProblems, type ActionRuleProblems, type InheritedConflict } from '../../blueprint/action-rules'
|
|
26
|
-
import { pickCurrentRevision } from '../../blueprint/dataset-revision'
|
|
26
|
+
import { datasetBindingCommand, pickCurrentRevision } from '../../blueprint/dataset-revision'
|
|
27
27
|
import { nameCheckWords, orgNameCheck } from '../../blueprint/org-name-check'
|
|
28
28
|
import {
|
|
29
29
|
assertPrunesLeaveNoDanglingReference,
|
|
30
30
|
diff,
|
|
31
|
+
driftOf,
|
|
32
|
+
editableChanges,
|
|
31
33
|
isEmpty,
|
|
32
34
|
satisfies,
|
|
35
|
+
type DatasetIndex,
|
|
33
36
|
type ModelOperation,
|
|
34
37
|
type Plan,
|
|
38
|
+
type RebindChange,
|
|
35
39
|
} from '../../blueprint/diff'
|
|
36
40
|
import {
|
|
37
41
|
API_NAME_RULE,
|
|
@@ -135,6 +139,66 @@ async function datasetNamesByRevision(
|
|
|
135
139
|
return names
|
|
136
140
|
}
|
|
137
141
|
|
|
142
|
+
/**
|
|
143
|
+
* What `plan` needs from the Datasets: the name each pinned revision belongs to, and the
|
|
144
|
+
* current revision of a Dataset a file names. The Dataset list names every current
|
|
145
|
+
* revision, so revisions are read only for a pin that is not current (it may have
|
|
146
|
+
* drifted) and for a Dataset a file names that its type does not read now (it may move).
|
|
147
|
+
* When every type reads its Dataset's current revision, nothing beyond the list is read.
|
|
148
|
+
* Undefined when this credential cannot read Datasets, so nothing is planned from a guess.
|
|
149
|
+
*/
|
|
150
|
+
async function readDatasetIndex(
|
|
151
|
+
client: BlueprintAuthoringApi,
|
|
152
|
+
files: AuthoredFile[],
|
|
153
|
+
live: DefinitionBundle,
|
|
154
|
+
): Promise<DatasetIndex | undefined> {
|
|
155
|
+
const pinOf = (apiName: string) => (live.objects.find((object) => object.apiName === apiName)
|
|
156
|
+
?.governance.sourceMappings[0] as { datasetRevisionId?: string } | undefined)?.datasetRevisionId
|
|
157
|
+
const named = files.flatMap((file) => {
|
|
158
|
+
const dataset = file.kind === 'object-type' ? (file.document.backing as { dataset?: unknown } | undefined)?.dataset : undefined
|
|
159
|
+
return typeof dataset === 'string' && dataset && live.objects.some((object) => object.apiName === file.apiName)
|
|
160
|
+
? [{ dataset, pin: pinOf(file.apiName) }]
|
|
161
|
+
: []
|
|
162
|
+
})
|
|
163
|
+
const pins = new Set([...named.flatMap((entry) => entry.pin ?? []), ...driftOf(live).map((pin) => pin.datasetRevisionId)])
|
|
164
|
+
if (named.length === 0 && pins.size === 0) return undefined
|
|
165
|
+
const readable = await readableDatasets(client)
|
|
166
|
+
if (!readable) return undefined
|
|
167
|
+
|
|
168
|
+
const byName = new Map(readable.flatMap((dataset) => (dataset.id && dataset.name ? [[dataset.name, { ...dataset, id: dataset.id }] as const] : [])))
|
|
169
|
+
const index: DatasetIndex = { nameByRevisionId: new Map(), current: new Map() }
|
|
170
|
+
for (const [name, dataset] of byName) {
|
|
171
|
+
if (dataset.currentRevisionId) index.nameByRevisionId.set(dataset.currentRevisionId, name)
|
|
172
|
+
}
|
|
173
|
+
const read = new Set<string>()
|
|
174
|
+
const readRevisions = async (name: string, dataset: { id: string; currentRevisionId?: string }) => {
|
|
175
|
+
if (read.has(name)) return
|
|
176
|
+
read.add(name)
|
|
177
|
+
const revisions = await client.datasetRevisions(dataset.id)
|
|
178
|
+
for (const revision of revisions) {
|
|
179
|
+
if (revision.id) index.nameByRevisionId.set(revision.id, name)
|
|
180
|
+
}
|
|
181
|
+
const current = pickCurrentRevision(dataset, revisions)
|
|
182
|
+
if (!current?.id) return
|
|
183
|
+
index.current.set(name, {
|
|
184
|
+
id: current.id,
|
|
185
|
+
...(current.schemaDigest ? { schemaDigest: current.schemaDigest } : {}),
|
|
186
|
+
...(current.columns ? { columns: current.columns.flatMap((column) => (column.name ? [column.name] : [])) } : {}),
|
|
187
|
+
})
|
|
188
|
+
}
|
|
189
|
+
for (const { dataset, pin } of named) {
|
|
190
|
+
const target = byName.get(dataset)
|
|
191
|
+
if (target && (!pin || target.currentRevisionId !== pin)) await readRevisions(dataset, target)
|
|
192
|
+
}
|
|
193
|
+
for (const pin of pins) {
|
|
194
|
+
for (const [name, dataset] of byName) {
|
|
195
|
+
if (index.nameByRevisionId.has(pin)) break
|
|
196
|
+
await readRevisions(name, dataset)
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return index
|
|
200
|
+
}
|
|
201
|
+
|
|
138
202
|
async function resolveRevision(
|
|
139
203
|
client: BlueprintAuthoringApi,
|
|
140
204
|
name: string,
|
|
@@ -308,34 +372,18 @@ async function computePlan(ctx: CommandContext): Promise<{
|
|
|
308
372
|
})
|
|
309
373
|
}
|
|
310
374
|
const { definition, revision } = await client.draft()
|
|
311
|
-
const
|
|
375
|
+
const datasets = await readDatasetIndex(client, files, definition)
|
|
376
|
+
const plan = withheldPrunes(diff(files, definition, revision, datasets), directory)
|
|
312
377
|
|
|
313
|
-
//
|
|
314
|
-
|
|
315
|
-
// `datasetNamesByRevision` had already read every dataset's revisions. A hundred
|
|
316
|
-
// types on one dataset cost a hundred redundant round trips.
|
|
378
|
+
// A type moving to another Dataset is no longer held to the old one's contract.
|
|
379
|
+
const rebound = new Set(plan.rebind.map((change) => change.objectApiName))
|
|
317
380
|
const drift: Array<{ objectApiName: string; datasetName: string; currentDigest: string }> = []
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
const
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
if (!dataset.id || !dataset.name) continue
|
|
325
|
-
const revisions = await client.datasetRevisions(dataset.id)
|
|
326
|
-
for (const revision of revisions) {
|
|
327
|
-
if (revision.id) names.set(revision.id, dataset.name)
|
|
328
|
-
}
|
|
329
|
-
const current = pickCurrentRevision(dataset, revisions)
|
|
330
|
-
if (current?.id) currentByDataset.set(dataset.name, { id: current.id, schemaDigest: current.schemaDigest })
|
|
331
|
-
}
|
|
332
|
-
for (const pin of plan.drift) {
|
|
333
|
-
const datasetName = names.get(pin.datasetRevisionId)
|
|
334
|
-
if (!datasetName) continue
|
|
335
|
-
const current = currentByDataset.get(datasetName)
|
|
336
|
-
if (!current?.schemaDigest || current.schemaDigest === pin.pinnedDigest) continue
|
|
337
|
-
drift.push({ objectApiName: pin.objectApiName, datasetName, currentDigest: current.schemaDigest })
|
|
338
|
-
}
|
|
381
|
+
for (const pin of plan.drift) {
|
|
382
|
+
const datasetName = datasets?.nameByRevisionId.get(pin.datasetRevisionId)
|
|
383
|
+
if (!datasetName || rebound.has(pin.objectApiName)) continue
|
|
384
|
+
const current = datasets?.current.get(datasetName)
|
|
385
|
+
if (!current?.schemaDigest || current.schemaDigest === pin.pinnedDigest) continue
|
|
386
|
+
drift.push({ objectApiName: pin.objectApiName, datasetName, currentDigest: current.schemaDigest })
|
|
339
387
|
}
|
|
340
388
|
const inherits = inheritance(ctx)
|
|
341
389
|
const actionProblems = actionRuleProblems(files, inherits.conflict)
|
|
@@ -1341,8 +1389,8 @@ export const blueprintApply: Command = {
|
|
|
1341
1389
|
|
|
1342
1390
|
// Drift is REPORTED, and blocks only the artifacts it is about. It used to throw
|
|
1343
1391
|
// before the plan was even shown, so a colleague's type reading a moved dataset
|
|
1344
|
-
// refused an unrelated creation and hid what the run would have done.
|
|
1345
|
-
// `bind`'s act
|
|
1392
|
+
// refused an unrelated creation and hid what the run would have done. Re-binding to
|
|
1393
|
+
// the same Dataset's new revision is `bind`'s act; the plan says so on the line.
|
|
1346
1394
|
const driftingNames = new Set(drift.map((entry) => entry.objectApiName))
|
|
1347
1395
|
const blocked = plan.operations.filter((operation) =>
|
|
1348
1396
|
operation.kind === 'object-type' && driftingNames.has(operation.apiName))
|
|
@@ -1354,6 +1402,16 @@ export const blueprintApply: Command = {
|
|
|
1354
1402
|
)
|
|
1355
1403
|
}
|
|
1356
1404
|
|
|
1405
|
+
// A rebind that cannot be sent stops the run before any write, with what to run instead.
|
|
1406
|
+
const refused = plan.rebind.filter((change): change is Extract<RebindChange, { refused: string }> => 'refused' in change)
|
|
1407
|
+
if (refused.length > 0) {
|
|
1408
|
+
throw new CliError(
|
|
1409
|
+
refused.map((change) => `${change.objectApiName} cannot move to Dataset "${change.to}": ${change.refused}.`).join('\n')
|
|
1410
|
+
+ '\nNothing was applied.',
|
|
1411
|
+
{ code: 'CONFLICT', hint: refused.map((change) => change.next).join('\n') },
|
|
1412
|
+
)
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1357
1415
|
// BOTH orderings resolved before the confirmation, not inside the write loop.
|
|
1358
1416
|
// Each refuses a mutual `compensationAction` pair, and a refusal that arrives
|
|
1359
1417
|
// after the creates have committed leaves the draft half-applied — which is what
|
|
@@ -1367,9 +1425,10 @@ export const blueprintApply: Command = {
|
|
|
1367
1425
|
const ordered = orderForApply(plan.operations)
|
|
1368
1426
|
const orderedPrunes = prune ? orderForPrune(plan.prunes, liveCompensations(definition)) : []
|
|
1369
1427
|
|
|
1428
|
+
const changeCount = plan.operations.length + plan.editable.length + plan.rebind.length
|
|
1370
1429
|
if (ctx.flags.yes !== true) {
|
|
1371
1430
|
throw new CliError(
|
|
1372
|
-
`This would apply ${
|
|
1431
|
+
`This would apply ${changeCount} change${changeCount === 1 ? '' : 's'}.\n`
|
|
1373
1432
|
+ `${renderPlan(plan, { prune })}\n\nRe-run with --yes to apply.`,
|
|
1374
1433
|
{ code: 'USAGE', hint: 'frontera blueprint apply --yes' },
|
|
1375
1434
|
)
|
|
@@ -1398,6 +1457,23 @@ export const blueprintApply: Command = {
|
|
|
1398
1457
|
}),
|
|
1399
1458
|
),
|
|
1400
1459
|
}
|
|
1460
|
+
// First, so a property this run adds can read a column only the new Dataset publishes.
|
|
1461
|
+
// The file naming the Dataset is the decision `--accept-contract-change` stands for in `bind`.
|
|
1462
|
+
for (const change of plan.rebind) {
|
|
1463
|
+
if ('refused' in change) continue
|
|
1464
|
+
current = `update object-type ${change.objectApiName} dataset: ${change.from ?? '(none)'} → ${change.to}`
|
|
1465
|
+
try {
|
|
1466
|
+
revision = (await client.draftCommand(datasetBindingCommand(change.objectId, change.datasetRevisionId, change.columnMappings, true), revision)).revision
|
|
1467
|
+
} catch (err) {
|
|
1468
|
+
if (!(err instanceof CliError)) throw err
|
|
1469
|
+
throw new CliError(`backing.dataset (${change.to}) was refused: ${err.message}`, {
|
|
1470
|
+
code: err.code,
|
|
1471
|
+
hint: `frontera blueprint bind ${change.objectApiName} --dataset ${change.to} --plan ./map.json`,
|
|
1472
|
+
cause: err,
|
|
1473
|
+
})
|
|
1474
|
+
}
|
|
1475
|
+
applied.push(current)
|
|
1476
|
+
}
|
|
1401
1477
|
for (const operation of ordered) {
|
|
1402
1478
|
const file = byKey.get(`${operation.kind}:${operation.apiName}`)!
|
|
1403
1479
|
current = `${operation.operation} ${operation.kind} ${operation.apiName}`
|
|
@@ -1411,6 +1487,24 @@ export const blueprintApply: Command = {
|
|
|
1411
1487
|
applied.push(current)
|
|
1412
1488
|
}
|
|
1413
1489
|
}
|
|
1490
|
+
// Last, against the draft as these writes left it: a type created above has its
|
|
1491
|
+
// id, and a property added above can be named.
|
|
1492
|
+
if (plan.editable.length) {
|
|
1493
|
+
const after = await client.draft()
|
|
1494
|
+
revision = after.revision
|
|
1495
|
+
for (const change of editableChanges(files, after.definition)) {
|
|
1496
|
+
current = `update object-type ${change.objectApiName}`
|
|
1497
|
+
const objectId = after.definition.objects.find((object) => object.apiName === change.objectApiName)?.id
|
|
1498
|
+
if (!objectId) throw new CliError(`No object type "${change.objectApiName}" on the draft to declare editable fields on.`, { code: 'NOT_FOUND', hint: 'frontera blueprint plan — re-plan against the current draft, then re-apply.' })
|
|
1499
|
+
try {
|
|
1500
|
+
revision = (await client.draftCommand({ kind: 'set_editable_properties', objectId, editableProperties: change.to }, revision)).revision
|
|
1501
|
+
} catch (err) {
|
|
1502
|
+
if (!(err instanceof CliError)) throw err
|
|
1503
|
+
throw new CliError(`backing.editable (${change.to.join(', ') || 'none'}) was refused: ${err.message}`, { code: err.code, ...(err.hint ? { hint: err.hint } : {}), cause: err })
|
|
1504
|
+
}
|
|
1505
|
+
applied.push(`${current} editable: ${change.to.join(', ') || '(none)'}`)
|
|
1506
|
+
}
|
|
1507
|
+
}
|
|
1414
1508
|
current = null
|
|
1415
1509
|
} catch (err) {
|
|
1416
1510
|
// What landed is as important as what failed: the draft is shared, and a
|
|
@@ -5,11 +5,9 @@ import type { Command, CommandContext } from '../types'
|
|
|
5
5
|
/**
|
|
6
6
|
* Declare which properties a governed Action may write.
|
|
7
7
|
*
|
|
8
|
-
* The `set_editable_properties` draft command
|
|
9
|
-
*
|
|
10
|
-
* from
|
|
11
|
-
* So the one declaration standing between a published ontology and an Action
|
|
12
|
-
* that can change it was reachable by raw HTTP alone.
|
|
8
|
+
* The `set_editable_properties` draft command, for one object type at a time.
|
|
9
|
+
* `apply` sends the same command for every file whose `backing.editable`
|
|
10
|
+
* differs from the draft; this verb reads or changes the set without a file.
|
|
13
11
|
*
|
|
14
12
|
* It is its own verb rather than a flag on `bind` because narrowing the set is
|
|
15
13
|
* a governed act with its own consequence — an edit already stored against a
|
|
@@ -26,6 +26,19 @@ interface KnowledgeRow {
|
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
|
|
29
|
+
/**
|
|
30
|
+
* These commands manage the knowledge bases Agents attach. The Knowledge app's
|
|
31
|
+
* bases (organization and workspace) are another thing with no command or
|
|
32
|
+
* project file, and no local runtime runs the app. Said only where the
|
|
33
|
+
* platform runs the app: elsewhere there is no such base to mistake for one.
|
|
34
|
+
*/
|
|
35
|
+
async function knowledgeAppNote(api: PlatformApi): Promise<string> {
|
|
36
|
+
return (await api.knowledgeAppRuns())
|
|
37
|
+
? ' A knowledge base from the Knowledge app is not one of these: it runs on the platform only, not on a local runtime. '
|
|
38
|
+
+ 'Choose it for an Agent on the platform, under the Agent\'s Knowledge page, and test that Agent there.'
|
|
39
|
+
: ''
|
|
40
|
+
}
|
|
41
|
+
|
|
29
42
|
/** Accept a base by name or id — `knowledge list` shows both. */
|
|
30
43
|
async function resolveKnowledgeRef(client: PlatformApi, ref: string): Promise<string> {
|
|
31
44
|
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
|
@@ -38,7 +51,7 @@ async function resolveKnowledgeRef(client: PlatformApi, ref: string): Promise<st
|
|
|
38
51
|
|
|
39
52
|
throw new CliError(`no knowledge base named "${ref}"`, {
|
|
40
53
|
code: 'NOT_FOUND',
|
|
41
|
-
hint: `known bases: ${rows.map((k) => k.name).filter(Boolean).join(', ') || '(none)'}`,
|
|
54
|
+
hint: `known bases: ${rows.map((k) => k.name).filter(Boolean).join(', ') || '(none)'}.${await knowledgeAppNote(client)}`,
|
|
42
55
|
})
|
|
43
56
|
}
|
|
44
57
|
|
|
@@ -61,7 +74,7 @@ const list: Command = {
|
|
|
61
74
|
data: rows,
|
|
62
75
|
text:
|
|
63
76
|
rows.length === 0
|
|
64
|
-
?
|
|
77
|
+
? `No knowledge bases in this workspace.${await knowledgeAppNote(api)}`
|
|
65
78
|
: // NAME first: it is the value `knowledge sources` takes, not the id.
|
|
66
79
|
// No `displayName` on these rows, so a column for it was always
|
|
67
80
|
// blank and shifted the description out of alignment.
|